Files
familysync/.planning/codebase/TESTING.md
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

12 KiB

Testing Patterns

Analysis Date: 2026-06-09

Test Framework

Runner:

  • Backend: Vitest 4.1.8, Node environment
  • Frontend: Vitest 4.1.8, jsdom environment
  • Config: apps/api/vitest.config.ts, apps/pwa/vitest.config.ts

Assertion Library:

  • Vitest built-in expect()
  • Testing Library (@testing-library/react, @testing-library/jest-dom) for component DOM assertions
  • jest-dom matchers extended via apps/pwa/src/test-setup.ts

Run Commands:

# Run all tests
pnpm test

# Run tests in watch mode
pnpm --filter @familysync/api test:watch
pnpm --filter @familysync/pwa test:watch

# Run with coverage (not configured yet)
vitest run --coverage

Test File Organization

Location:

  • Backend: apps/api/tests/ parallel to apps/api/src/ — mirrors source structure
  • Frontend: Co-located with source files — src/components/Foo.tsxsrc/components/Foo.test.tsx

Naming:

  • Test files: {module}.test.ts or .test.tsx
  • Fixtures: apps/api/tests/fixtures/ — fixture files (e.g., weekly-dst.ics) loaded by test helpers

Structure:

apps/api/tests/
├── health.test.ts                 # End-to-end test for GET /health
├── auth/
│   ├── devBypass.test.ts
│   └── user.test.ts
├── broker/
│   ├── expand.test.ts             # expandOccurrences() unit tests
│   ├── poller.test.ts
│   ├── outboxWorker.test.ts
│   ├── sync.test.ts
│   ├── vevent.test.ts
│   ├── write.test.ts
│   └── crypto.test.ts
├── routes/                        # Route handler tests TBD
├── helpers/                       # Test utility functions
└── fixtures/
    ├── weekly-dst.ics             # DST test fixture (weekly recurrence)
    └── allday-birthday.ics        # All-day recurrence fixture

apps/pwa/src/
├── api/client.test.ts
├── lib/
│   ├── colorUtils.test.ts
│   ├── eventDateTime.test.ts
│   ├── hydrateEvents.test.ts
│   ├── loginRedirect.test.ts
│   └── calendarConfig.test.ts
├── components/
│   ├── InstallPrompt.test.tsx
│   └── ...
└── store/
    └── (Zustand store tested via client.test.ts)

Test Structure

Suite Organization:

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

describe('GET /health', () => {
  it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => {
    // Arrange
    const { app } = await import('../src/index.js');

    // Act
    const res = await app.request('/health');

    // Assert
    expect(res.status).toBe(200);
    const body = (await res.json()) as { ok: boolean; db: string };
    expect(body.ok).toBe(true);
  });

  it('returns 503 when DB round-trip throws', async () => {
    // Arrange
    const { db } = await import('../src/db/client.js');
    vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed'));

    // Act
    const { app } = await import('../src/index.js');
    const res = await app.request('/health');

    // Assert
    expect(res.status).toBe(503);
  });
});

Patterns:

  • Async test functions with full await chain
  • Hono request testing: app.request(path) returns a Response object
  • Mock setup in beforeEach; cleanup in afterEach with vi.unstubAllGlobals() or vi.clearAllMocks()
  • Descriptive test names following "should [action] when [condition]" or "[verb] [noun]" pattern
  • Arrange-Act-Assert (AAA) comment structure for multi-step tests

Mocking

Framework: Vitest vi object (vi.mock, vi.mocked, vi.fn, vi.stubGlobal)

Module Mocking:

// Hoist vi.mock() calls to the top of the module (Vitest requirement)
vi.mock('../src/db/client.js', () => ({
  db: {
    execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
  },
}));

Function Mocking:

const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce({
  ok: true,
  json: async () => ({ uid: 'test-uid' }),
} as Response);

// Call the function under test
await createEvent(payload);

// Assert the mock was called correctly
expect(mockFetch).toHaveBeenCalledWith(
  '/api/events/create',
  expect.objectContaining({
    method: 'POST',
    credentials: 'include',
  }),
);

Global Stubs (Frontend):

beforeEach(() => {
  vi.stubGlobal('fetch', vi.fn());
});

afterEach(() => {
  vi.unstubAllGlobals();
});

What to Mock:

  • External I/O: database (via vi.mock on src/db/client.js)
  • Network calls: fetch (via vi.stubGlobal('fetch', ...))
  • Environment-dependent code: window.matchMedia (jsdom polyfill, see test-setup.ts)
  • Time-dependent code: Date, setTimeout (if needed; not used currently)

What NOT to Mock:

  • Pure utility functions — test them directly (colorUtils, eventDateTime, hydrateEvents)
  • Zod validation schemas — test with real payloads
  • Zustand stores — instantiate real store, call real methods
  • Hono app logic — use app.request() to test end-to-end
  • iCalendar parsing (ical.js) — test with real .ics fixtures, not mocks

Fixtures and Factories

Test Data (Backend): Fixture files are .ics (iCalendar) strings stored in apps/api/tests/fixtures/:

// Load fixture file
const rawVevent = readFileSync(join(FIXTURES, 'weekly-dst.ics'), 'utf8');

// Use in test
const occurrences = expandOccurrences(
  rawVevent,
  new Date('2026-03-01T00:00:00Z'),
  new Date('2026-04-01T00:00:00Z'),
  1,
  'My Calendar',
  1,
  'Alice',
  '#4A90D9',
  false,
);

Test Data (Frontend): Inline mock objects in test files (no factory pattern needed yet):

vi.mocked(fetch).mockResolvedValueOnce({
  ok: true,
  json: async () => ({
    calendars: [
      {
        url: 'https://caldav.fastmail.com/cal1',
        displayName: 'My Calendar',
        color: '#4A90D9',
        isShared: false,
      },
      {
        url: 'https://caldav.fastmail.com/cal2',
        displayName: 'Family',
        color: '#F25C7A',
        isShared: true,
      },
    ],
  }),
} as Response);

Location:

  • Fixture files: apps/api/tests/fixtures/ — raw .ics strings for iCalendar tests
  • Mock payloads: inline in test files (api/client.test.ts, etc.)

Coverage

Requirements: None enforced (no coverage thresholds in vitest.config.ts)

Current State:

  • Backend: Partial coverage — broker modules (expand, sync, write, crypto, vevent) tested; route handlers mostly untested
  • Frontend: Good coverage of utility functions (colorUtils, eventDateTime, hydrateEvents, calendarConfig) and API client

View Coverage:

# Generate coverage report (requires @vitest/coverage-v8)
vitest run --coverage

Test Types

Unit Tests:

  • Scope: Single function or small module in isolation (mocks external dependencies)
  • Approach: Test input → output contracts, edge cases, error conditions
  • Examples: lib/colorUtils.test.ts, broker/crypto.test.ts, api/client.test.ts

Integration Tests:

  • Scope: Multi-module behavior (e.g., route handler + DB + auth middleware)
  • Approach: Test realistic user flows using app.request() for HTTP semantics
  • Examples: health.test.ts (GET /health with mocked DB)
  • No external API calls (Fastmail, Authelia mocked)

E2E Tests:

  • Not implemented; would require running a real server + browser
  • Currently using playwright-cli skill for browser-based smoke tests of UI (per project CLAUDE.md)

Common Patterns

Async Testing:

it('returns { uid } on success', async () => {
  vi.mocked(fetch).mockResolvedValueOnce({
    ok: true,
    json: async () => ({ uid: 'returned-uid' }),
  } as Response);

  const { createEvent } = await import('./client.js');
  const result = await createEvent(payload);

  expect(result).toEqual({ uid: 'returned-uid' });
});

Error Testing:

it('throws on non-ok response', async () => {
  vi.mocked(fetch).mockResolvedValueOnce({
    ok: false,
    status: 400,
    json: async () => ({ error: 'Bad Request' }),
  } as Response)

  const { createEvent } = await import('./client.js')

  await expect(
    createEvent({ title: '', ... })
  ).rejects.toThrow()
})

Status Code Testing:

it('returns 503 when DB round-trip throws', async () => {
  const { db } = await import('../src/db/client.js');
  vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed'));

  const { app } = await import('../src/index.js');
  const res = await app.request('/health');

  expect(res.status).toBe(503);
});

Fixture-Based Testing:

describe('expandOccurrences — DST correctness', () => {
  it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
    const rawVevent = loadFixture('weekly-dst.ics');
    const windowStart = new Date('2026-03-01T00:00:00Z');
    const windowEnd = new Date('2026-04-01T00:00:00Z');

    const occurrences = expandOccurrences(
      rawVevent,
      windowStart,
      windowEnd,
      1,
      'My Calendar',
      1,
      'Alice',
      '#4A90D9',
      false,
    );

    // Check DST correctness: all occurrences must show hour === 10 local time
    for (const occ of occurrences) {
      expect(occ.start).toMatch(/T10:00:00/);
      expect(occ.start).toContain('[America/New_York]');
    }

    // Explicitly check pre- and post-transition occurrences
    const preTransition = occurrences.find((o) => o.start.includes('2026-03-01'));
    const postTransition = occurrences.find((o) => o.start.includes('2026-03-15'));

    expect(preTransition!.start).toContain('-05:00[America/New_York]'); // EST
    expect(postTransition!.start).toContain('-04:00[America/New_York]'); // EDT
  });
});

Zustand Store Testing:

describe('calendarStore', () => {
  it('setEventForm(true, edit, some-uid) updates all three keys', async () => {
    const { useCalendarStore } = await import('../store/calendarStore.js');
    useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid');
    const state = useCalendarStore.getState();

    expect(state.eventFormOpen).toBe(true);
    expect(state.eventFormMode).toBe('edit');
    expect(state.eventFormUid).toBe('some-uid');
  });
});

Test Setup

Backend (Node environment):

  • vitest.config.ts specifies environment: 'node' with globals: true
  • No test-setup file needed (Node has built-in globals)
  • Modules imported via await import(...) to enable per-test mocking

Frontend (jsdom environment):

  • vitest.config.ts specifies environment: 'jsdom' with globals: true and setupFiles: ['./src/test-setup.ts']
  • test-setup.ts polyfills window.matchMedia (jsdom doesn't implement CSSOM MediaQueryList)
  • test-setup.ts extends expect with jest-dom matchers
  • Timezone pinned to UTC via env: { TZ: 'UTC' } for deterministic date tests (WR-05)

Example (from apps/pwa/vitest.config.ts):

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    env: { TZ: 'UTC' },
  },
});

Example (from apps/pwa/src/test-setup.ts):

import '@testing-library/jest-dom';

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    // ... other MediaQueryList methods
  }),
});

Known Testing Gaps

Backend Route Handlers:

  • GET /api/events, POST /api/events/create, PATCH /api/events/:uid/edit, DELETE /api/events/:uid — no route tests yet (in scope for Phase 5 / Plan 05)
  • GET /api/events/writable-calendars, GET /api/events/sync-status — no route tests
  • SSE route (/api/sse) — not tested
  • Auth flow tests (dev-bypass, OIDC session) partially covered; integration tests with Authelia not applicable

Frontend Components:

  • EventForm, DeleteConfirmationDialog, CalendarShell — no component tests yet
  • SSE event listener integration (real-time list updates) — not tested

Integration:

  • Full end-to-end flow (login → fetch events → create event → poll sync-status) — not covered
  • Database transaction rollback on error — not explicitly tested

Testing analysis: 2026-06-09