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.
447 lines
12 KiB
Markdown
447 lines
12 KiB
Markdown
# 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:**
|
|
|
|
```bash
|
|
# 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.tsx` → `src/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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
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):**
|
|
|
|
```typescript
|
|
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/`:
|
|
|
|
```typescript
|
|
// 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):
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```bash
|
|
# 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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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`):**
|
|
|
|
```typescript
|
|
export default defineConfig({
|
|
test: {
|
|
environment: 'jsdom',
|
|
globals: true,
|
|
setupFiles: ['./src/test-setup.ts'],
|
|
env: { TZ: 'UTC' },
|
|
},
|
|
});
|
|
```
|
|
|
|
**Example (from `apps/pwa/src/test-setup.ts`):**
|
|
|
|
```typescript
|
|
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_
|