Testing

API Testing Guide

Comprehensive guide to testing applications built with the Cloudillo API.

Testing Strategy

Testing Pyramid

        /\
       /E2E\       Few, slow, expensive
      /------\
     /  API  \     More, medium speed
    /----------\
   / Unit Tests \  Many, fast, cheap
  /--------------\

Unit Tests: Test individual functions and components in isolation API/Integration Tests: Test API interactions and service integration E2E Tests: Test complete user workflows

Unit Testing

Testing API Client Functions

import { describe, test, expect, vi } from 'vitest'
import { ActionService } from './action-service'

describe('ActionService', () => {
  test('formats action query parameters correctly', () => {
    const service = new ActionService(mockClient)

    const query = service.buildQuery({
      type: 'POST',
      status: 'A',
      _limit: 20,
      _offset: 0
    })

    expect(query.toString()).toBe('type=POST&status=A&_limit=20&_offset=0')
  })

  test('converts timestamps to Unix seconds', () => {
    const service = new ActionService(mockClient)

    const action = service.prepareAction({
      type: 'POST',
      content: { text: 'Hello' },
      published: new Date('2025-01-01T00:00:00Z')
    })

    expect(action.published).toBe(1735689600)
  })

  test('validates action type', () => {
    const service = new ActionService(mockClient)

    expect(() => {
      service.validateAction({ type: 'INVALID' })
    }).toThrow('Invalid action type')
  })
})

Testing State Management

import { describe, test, expect } from 'vitest'
import { TokenManager } from './token-manager'

describe('TokenManager', () => {
  test('detects expired tokens', () => {
    const manager = new TokenManager()

    // Token expired 1 hour ago
    const expiredToken = createToken({ exp: Math.floor(Date.now() / 1000) - 3600 })

    expect(manager.isExpired(expiredToken)).toBe(true)
  })

  test('schedules refresh before expiry', () => {
    const manager = new TokenManager()

    // Token expires in 20 minutes
    const token = createToken({ exp: Math.floor(Date.now() / 1000) + 1200 })

    const refreshTime = manager.getRefreshTime(token)
    const now = Date.now()

    // Should refresh in ~10 minutes (10 min before expiry)
    expect(refreshTime).toBeGreaterThan(now)
    expect(refreshTime).toBeLessThan(now + 11 * 60 * 1000)
  })

  test('caches tokens by scope', () => {
    const manager = new TokenManager()

    manager.setToken('read', 'token1')
    manager.setToken('write', 'token2')

    expect(manager.getToken('read')).toBe('token1')
    expect(manager.getToken('write')).toBe('token2')
  })
})

Testing Utility Functions

import { describe, test, expect } from 'vitest'
import { validateIdTag, parseActionId, formatTimestamp } from './utils'

describe('Utility Functions', () => {
  test('validates idTag format', () => {
    expect(validateIdTag('alice@example.com')).toBe(true)
    expect(validateIdTag('invalid')).toBe(false)
    expect(validateIdTag('@example.com')).toBe(false)
  })

  test('parses action ID components', () => {
    const parsed = parseActionId('act_abc123')

    expect(parsed.prefix).toBe('act')
    expect(parsed.id).toBe('abc123')
  })

  test('formats Unix timestamp to readable date', () => {
    const timestamp = 1735689600  // 2025-01-01 00:00:00 UTC
    const formatted = formatTimestamp(timestamp)

    expect(formatted).toBe('2025-01-01 00:00:00')
  })
})

Integration Testing

Mocking Fetch Requests

import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'
import { CloudilloClient } from './client'

describe('CloudilloClient Integration', () => {
  let client: CloudilloClient

  beforeEach(() => {
    global.fetch = vi.fn()
    client = new CloudilloClient('http://localhost:3000', tokenManager)
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  test('creates action via API', async () => {
    const mockResponse = {
      data: {
        actionId: 'act_123',
        type: 'POST',
        content: { text: 'Hello' }
      },
      time: 1735689600,
      reqId: 'req_abc'
    }

    ;(global.fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => mockResponse
    })

    const result = await client.actions.create({
      type: 'POST',
      content: { text: 'Hello' },
      audience: ['public'],
      published: 1735689600
    })

    expect(fetch).toHaveBeenCalledWith(
      'http://localhost:3000/api/action',
      expect.objectContaining({
        method: 'POST',
        headers: expect.objectContaining({
          'Content-Type': 'application/json',
          'Authorization': expect.stringContaining('Bearer ')
        })
      })
    )

    expect(result.data.actionId).toBe('act_123')
  })

  test('handles API errors', async () => {
    ;(global.fetch as any).mockResolvedValueOnce({
      ok: false,
      status: 401,
      json: async () => ({
        error: {
          code: 'E-AUTH-UNAUTH',
          message: 'Unauthorized access'
        }
      })
    })

    await expect(
      client.actions.list()
    ).rejects.toThrow('Unauthorized access')
  })

  test('retries on 5xx errors', async () => {
    ;(global.fetch as any)
      .mockResolvedValueOnce({
        ok: false,
        status: 503,
        json: async () => ({ error: { message: 'Service unavailable' } })
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ data: [] })
      })

    const result = await client.actions.list()

    expect(fetch).toHaveBeenCalledTimes(2)
    expect(result.data).toEqual([])
  })
})

Testing with MSW (Mock Service Worker)

import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'vitest'

const server = setupServer(
  // Mock login endpoint
  rest.post('http://localhost:3000/api/auth/login', (req, res, ctx) => {
    const { email, password } = req.body as any

    if (email === 'test@example.com' && password === 'password') {
      return res(
        ctx.json({
          data: {
            token: 'mock-jwt-token',
            tenant: { idTag: 'test@example.com' }
          }
        })
      )
    }

    return res(
      ctx.status(401),
      ctx.json({
        error: {
          code: 'E-AUTH-BADCRED',
          message: 'Invalid credentials'
        }
      })
    )
  }),

  // Mock actions list endpoint
  rest.get('http://localhost:3000/api/action', (req, res, ctx) => {
    const type = req.url.searchParams.get('type')
    const limit = parseInt(req.url.searchParams.get('_limit') || '50')

    return res(
      ctx.json({
        data: [
          {
            actionId: 'act_1',
            type: type || 'POST',
            content: { text: 'Test post' },
            createdAt: 1735689600
          }
        ],
        pagination: {
          total: 1,
          offset: 0,
          limit,
          hasMore: false
        }
      })
    )
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('API Integration with MSW', () => {
  test('login flow', async () => {
    const result = await fetch('http://localhost:3000/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: 'test@example.com',
        password: 'password'
      })
    })

    const data = await result.json()
    expect(data.data.token).toBe('mock-jwt-token')
  })

  test('fetch filtered actions', async () => {
    const result = await fetch('http://localhost:3000/api/action?type=POST&_limit=20')
    const data = await result.json()

    expect(data.data).toHaveLength(1)
    expect(data.data[0].type).toBe('POST')
    expect(data.pagination.limit).toBe(20)
  })
})

E2E Testing

Testing with Playwright

import { test, expect } from '@playwright/test'

test.describe('Cloudillo Social Features', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto('http://localhost:3000/login')
    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'password')
    await page.click('button[type="submit"]')
    await page.waitForURL('http://localhost:3000/feed')
  })

  test('create a post', async ({ page }) => {
    // Navigate to create post
    await page.click('[data-testid="create-post-button"]')

    // Fill in post content
    await page.fill('[data-testid="post-textarea"]', 'This is a test post')

    // Submit post
    await page.click('[data-testid="submit-post-button"]')

    // Wait for post to appear
    await page.waitForSelector('[data-testid="post-item"]')

    // Verify post content
    const postContent = await page.textContent('[data-testid="post-content"]')
    expect(postContent).toContain('This is a test post')
  })

  test('comment on a post', async ({ page }) => {
    // Find first post
    const firstPost = page.locator('[data-testid="post-item"]').first()

    // Click comment button
    await firstPost.locator('[data-testid="comment-button"]').click()

    // Fill comment
    await page.fill('[data-testid="comment-textarea"]', 'Great post!')

    // Submit comment
    await page.click('[data-testid="submit-comment-button"]')

    // Verify comment appears
    await page.waitForSelector('[data-testid="comment-item"]')
    const commentText = await page.textContent('[data-testid="comment-content"]')
    expect(commentText).toContain('Great post!')
  })

  test('upload file', async ({ page }) => {
    await page.goto('http://localhost:3000/files')

    // Upload file
    const fileInput = page.locator('input[type="file"]')
    await fileInput.setInputFiles('./test-fixtures/image.jpg')

    // Wait for upload to complete
    await page.waitForSelector('[data-testid="upload-success"]')

    // Verify file appears in list
    const fileName = await page.textContent('[data-testid="file-name"]')
    expect(fileName).toContain('image.jpg')
  })

  test('real-time updates via WebSocket', async ({ page, context }) => {
    // Open two pages
    const page2 = await context.newPage()
    await page2.goto('http://localhost:3000/feed')

    // Create post on first page
    await page.click('[data-testid="create-post-button"]')
    await page.fill('[data-testid="post-textarea"]', 'Real-time test')
    await page.click('[data-testid="submit-post-button"]')

    // Verify post appears on second page (via WebSocket)
    await page2.waitForSelector(':has-text("Real-time test")', { timeout: 5000 })
    const postOnPage2 = await page2.textContent(':has-text("Real-time test")')
    expect(postOnPage2).toContain('Real-time test')
  })
})

Testing API Directly in E2E

import { test, expect } from '@playwright/test'

test.describe('API E2E Tests', () => {
  let token: string

  test.beforeAll(async ({ request }) => {
    // Login to get token
    const response = await request.post('http://localhost:3000/api/auth/login', {
      data: {
        email: 'test@example.com',
        password: 'password'
      }
    })

    const data = await response.json()
    token = data.data.token
  })

  test('complete post lifecycle', async ({ request }) => {
    // Create post
    const createResponse = await request.post('http://localhost:3000/api/action', {
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      data: {
        type: 'POST',
        content: { text: 'E2E test post' },
        audience: ['public'],
        published: Math.floor(Date.now() / 1000)
      }
    })

    expect(createResponse.ok()).toBeTruthy()
    const createData = await createResponse.json()
    const actionId = createData.data.actionId

    // Fetch post
    const getResponse = await request.get(
      `http://localhost:3000/api/action/${actionId}`,
      {
        headers: { 'Authorization': `Bearer ${token}` }
      }
    )

    const getData = await getResponse.json()
    expect(getData.data.content.text).toBe('E2E test post')

    // Delete post
    const deleteResponse = await request.delete(
      `http://localhost:3000/api/action/${actionId}`,
      {
        headers: { 'Authorization': `Bearer ${token}` }
      }
    )

    expect(deleteResponse.ok()).toBeTruthy()

    // Verify deleted
    const verifyResponse = await request.get(
      `http://localhost:3000/api/action/${actionId}`,
      {
        headers: { 'Authorization': `Bearer ${token}` }
      }
    )

    expect(verifyResponse.status()).toBe(404)
  })

  test('pagination works correctly', async ({ request }) => {
    // Fetch first page
    const page1 = await request.get(
      'http://localhost:3000/api/action?_limit=10&_offset=0',
      {
        headers: { 'Authorization': `Bearer ${token}` }
      }
    )

    const page1Data = await page1.json()
    expect(page1Data.data).toHaveLength(10)
    expect(page1Data.pagination.offset).toBe(0)

    // Fetch second page
    const page2 = await request.get(
      'http://localhost:3000/api/action?_limit=10&_offset=10',
      {
        headers: { 'Authorization': `Bearer ${token}` }
      }
    )

    const page2Data = await page2.json()
    expect(page2Data.pagination.offset).toBe(10)

    // Verify different results
    const page1Ids = page1Data.data.map((a: any) => a.actionId)
    const page2Ids = page2Data.data.map((a: any) => a.actionId)
    expect(page1Ids).not.toEqual(page2Ids)
  })
})

Component Testing

Testing React Components with API

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ActionFeed } from './ActionFeed'

describe('ActionFeed Component', () => {
  test('renders loading state', () => {
    render(<ActionFeed />)
    expect(screen.getByText('Loading...')).toBeInTheDocument()
  })

  test('renders actions after loading', async () => {
    const mockActions = [
      {
        actionId: 'act_1',
        type: 'POST',
        content: { text: 'Test post 1' },
        createdAt: 1735689600
      }
    ]

    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ data: mockActions })
    })

    render(<ActionFeed />)

    await waitFor(() => {
      expect(screen.getByText('Test post 1')).toBeInTheDocument()
    })
  })

  test('loads more on scroll', async () => {
    const page1 = [
      { actionId: 'act_1', type: 'POST', content: { text: 'Post 1' } }
    ]
    const page2 = [
      { actionId: 'act_2', type: 'POST', content: { text: 'Post 2' } }
    ]

    global.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          data: page1,
          pagination: { hasMore: true }
        })
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          data: page2,
          pagination: { hasMore: false }
        })
      })

    const { container } = render(<ActionFeed />)

    // Wait for first page
    await waitFor(() => {
      expect(screen.getByText('Post 1')).toBeInTheDocument()
    })

    // Scroll to bottom
    const scrollElement = container.querySelector('[data-testid="feed-container"]')
    scrollElement?.scrollTo(0, scrollElement.scrollHeight)

    // Wait for second page
    await waitFor(() => {
      expect(screen.getByText('Post 2')).toBeInTheDocument()
    })
  })

  test('handles create post', async () => {
    global.fetch = vi.fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ data: [] })  // Initial load
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          data: {
            actionId: 'act_new',
            type: 'POST',
            content: { text: 'New post' }
          }
        })
      })

    render(<ActionFeed />)

    // Fill in post
    const textarea = screen.getByPlaceholderText('What\'s on your mind?')
    await userEvent.type(textarea, 'New post')

    // Submit
    const submitButton = screen.getByRole('button', { name: 'Post' })
    await userEvent.click(submitButton)

    // Verify API call
    await waitFor(() => {
      expect(fetch).toHaveBeenCalledWith(
        expect.stringContaining('/api/action'),
        expect.objectContaining({
          method: 'POST',
          body: expect.stringContaining('New post')
        })
      )
    })
  })
})

Load Testing

Testing with k6

import http from 'k6/http'
import { check, sleep } from 'k6'

export const options = {
  stages: [
    { duration: '1m', target: 50 },   // Ramp up to 50 users
    { duration: '3m', target: 50 },   // Stay at 50 users
    { duration: '1m', target: 100 },  // Ramp up to 100 users
    { duration: '3m', target: 100 },  // Stay at 100 users
    { duration: '1m', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% of requests should be below 500ms
    http_req_failed: ['rate<0.01'],    // Error rate should be below 1%
  },
}

const BASE_URL = 'http://localhost:3000'
let token = ''

export function setup() {
  // Login to get token
  const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    email: 'test@example.com',
    password: 'password'
  }), {
    headers: { 'Content-Type': 'application/json' }
  })

  const loginData = JSON.parse(loginRes.body)
  return { token: loginData.data.token }
}

export default function (data) {
  const headers = {
    'Authorization': `Bearer ${data.token}`,
    'Content-Type': 'application/json'
  }

  // Test 1: Fetch actions
  const listRes = http.get(`${BASE_URL}/api/action?_limit=20`, { headers })
  check(listRes, {
    'status is 200': (r) => r.status === 200,
    'has data array': (r) => JSON.parse(r.body).data !== undefined,
  })

  sleep(1)

  // Test 2: Create action
  const createRes = http.post(
    `${BASE_URL}/api/action`,
    JSON.stringify({
      type: 'POST',
      content: { text: 'Load test post' },
      audience: ['public'],
      published: Math.floor(Date.now() / 1000)
    }),
    { headers }
  )

  check(createRes, {
    'create status is 200': (r) => r.status === 200,
    'has actionId': (r) => JSON.parse(r.body).data.actionId !== undefined,
  })

  const actionId = JSON.parse(createRes.body).data.actionId

  sleep(1)

  // Test 3: Get specific action
  const getRes = http.get(`${BASE_URL}/api/action/${actionId}`, { headers })
  check(getRes, {
    'get status is 200': (r) => r.status === 200,
  })

  sleep(1)

  // Test 4: Delete action
  const delRes = http.del(`${BASE_URL}/api/action/${actionId}`, null, { headers })
  check(delRes, {
    'delete status is 200': (r) => r.status === 200,
  })

  sleep(1)
}

Test Data Management

Fixtures and Factories

// test/fixtures/actions.ts
export const actionFixtures = {
  post: {
    actionId: 'act_post_1',
    type: 'POST',
    content: { text: 'Test post content' },
    audience: ['public'],
    issuerTag: 'alice@example.com',
    createdAt: 1735689600,
    published: 1735689600,
    status: 'A'
  },
  comment: {
    actionId: 'act_comment_1',
    type: 'COMMENT',
    content: { text: 'Test comment' },
    parentId: 'act_post_1',
    issuerTag: 'bob@example.com',
    createdAt: 1735689700,
    status: 'A'
  }
}

// test/factories/action-factory.ts
export class ActionFactory {
  static create(overrides: Partial<Action> = {}): Action {
    return {
      actionId: `act_${Math.random().toString(36).substr(2, 9)}`,
      type: 'POST',
      content: { text: 'Default post content' },
      audience: ['public'],
      issuerTag: 'test@example.com',
      createdAt: Math.floor(Date.now() / 1000),
      published: Math.floor(Date.now() / 1000),
      status: 'A',
      ...overrides
    }
  }

  static createMany(count: number, overrides: Partial<Action> = {}): Action[] {
    return Array.from({ length: count }, () => this.create(overrides))
  }

  static createPost(text: string): Action {
    return this.create({
      type: 'POST',
      content: { text }
    })
  }

  static createComment(parentId: string, text: string): Action {
    return this.create({
      type: 'COMMENT',
      parentId,
      content: { text }
    })
  }
}

// Usage in tests
const post = ActionFactory.createPost('Hello world')
const comment = ActionFactory.createComment(post.actionId, 'Nice post!')
const manyPosts = ActionFactory.createMany(10)

Database Seeding for Tests

// test/seed.ts
export async function seedTestData(token: string) {
  const baseUrl = 'http://localhost:3000'

  // Create test users
  const users = [
    { email: 'alice@example.com', name: 'Alice' },
    { email: 'bob@example.com', name: 'Bob' }
  ]

  // Create test posts
  const posts = []
  for (let i = 0; i < 10; i++) {
    const response = await fetch(`${baseUrl}/api/action`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        type: 'POST',
        content: { text: `Test post ${i}` },
        audience: ['public'],
        published: Math.floor(Date.now() / 1000)
      })
    })
    const { data } = await response.json()
    posts.push(data)
  }

  return { users, posts }
}

// Use in tests
beforeAll(async () => {
  const { posts } = await seedTestData(testToken)
  testData = { posts }
})

Continuous Integration

GitHub Actions Workflow

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:unit

      - name: Upload coverage
        uses: codecov/codecov-action@v3

  integration-tests:
    runs-on: ubuntu-latest
    services:
      cloudillo:
        image: cloudillo/server:latest
        ports:
          - 3000:3000

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - name: Install dependencies
        run: npm ci

      - name: Wait for server
        run: npx wait-on http://localhost:3000/api/auth/login

      - name: Run integration tests
        run: npm run test:integration

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright
        run: npx playwright install --with-deps

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-results
          path: test-results/

Best Practices

Test Organization

tests/
├── unit/
│   ├── services/
│   │   ├── action-service.test.ts
│   │   ├── profile-service.test.ts
│   │   └── file-service.test.ts
│   └── utils/
│       ├── validation.test.ts
│       └── formatting.test.ts
├── integration/
│   ├── auth-flow.test.ts
│   ├── action-crud.test.ts
│   └── file-upload.test.ts
├── e2e/
│   ├── social-features.spec.ts
│   ├── file-management.spec.ts
│   └── real-time.spec.ts
├── fixtures/
│   ├── actions.ts
│   ├── profiles.ts
│   └── files.ts
├── factories/
│   └── action-factory.ts
└── helpers/
    ├── setup.ts
    └── teardown.ts

Writing Maintainable Tests

// ✅ Good - descriptive test names
test('should return 401 when token is expired', async () => { ... })

// ❌ Bad - vague test name
test('auth test', async () => { ... })

// ✅ Good - test one thing
test('validates email format', () => {
  expect(validateEmail('test@example.com')).toBe(true)
})

test('rejects invalid email format', () => {
  expect(validateEmail('invalid')).toBe(false)
})

// ❌ Bad - tests multiple things
test('email validation', () => {
  expect(validateEmail('test@example.com')).toBe(true)
  expect(validateEmail('invalid')).toBe(false)
  expect(validateEmail('')).toBe(false)
})

// ✅ Good - clear arrange-act-assert
test('creates action successfully', async () => {
  // Arrange
  const action = ActionFactory.createPost('Hello')

  // Act
  const result = await api.actions.create(action)

  // Assert
  expect(result.data.actionId).toBeDefined()
  expect(result.data.type).toBe('POST')
})

Summary

Key testing strategies for Cloudillo API:

  1. Unit Tests: Test business logic and utilities in isolation
  2. Integration Tests: Test API interactions with mocked services
  3. E2E Tests: Test complete user workflows
  4. Component Tests: Test UI components with API interactions
  5. Load Tests: Test performance under load
  6. CI/CD: Automate testing in CI pipeline

Aim for 80%+ code coverage while focusing on critical paths and edge cases.