/** * RED test scaffold: broker/outboxWorker.ts — outbox state machine (D-04, D-07, D-08) * * Behaviors under test: * 1. runOutboxDrain transitions pending→done on mock 204 response * 2. runOutboxDrain transitions pending→failed on mock 412 (conflict, no retry), triggers re-sync * 3. runOutboxDrain transitions pending→backoff (nextAttemptAt advanced, attemptCount++) * on mock 500 (transient error) * 4. runOutboxDrain transitions pending→dead when attemptCount reaches MAX_ATTEMPTS * 5. Edit-as-move (D-04): create row processed BEFORE the linked delete row (groupId) * * These tests FAIL (RED) because broker/outboxWorker.ts does not exist yet. * They will turn GREEN in Plan 03-03 when the implementation is added. */ import { describe, it, expect, vi, beforeEach } from 'vitest' // This import fails (RED) — broker/outboxWorker.ts does not exist yet. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore intentional RED import import { runOutboxDrain } from '../../src/broker/outboxWorker.js' // ── Drizzle DB mock ──────────────────────────────────────────────────────── // Follows the pattern from PATTERNS.md §Drizzle DB mock in tests. // // vi.hoisted() is required for variables referenced inside vi.mock() factories. // vi.mock() is hoisted to the top of the file by vitest's transform; without // vi.hoisted(), variables declared with const/let are in the TDZ when the factory // runs (static imports trigger module loading before declarations are evaluated). const { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword, } = vi.hoisted(() => { const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) // mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status) // db.select().from(calendarOutbox).where(...) — resolves to the row array const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])) // mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02) // db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])) const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending }) const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }) // By default returns a dummy password so loadClientForUser succeeds const mockDecryptPassword = vi.fn().mockReturnValue('app-password') return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword } }) let mockPendingRows: unknown[] = [] // Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where() const FAKE_CRED_ROW = { userId: 42, fastmailEmail: 'test@fastmail.com', encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}', } vi.mock('../../src/db/client.js', () => ({ db: { select: mockSelectFn, update: mockUpdate, }, })) // Mock write functions — these are called by outboxWorker for the actual CalDAV ops vi.mock('../../src/broker/write.js', () => ({ createCalendarEvent: vi.fn(), updateCalendarEvent: vi.fn(), deleteCalendarEvent: vi.fn(), })) // Mock sync — called after successful write (D-06) vi.mock('../../src/broker/sync.js', () => ({ syncCalendar: vi.fn().mockResolvedValue(undefined), })) // Mock client creation — the worker needs a DAVClient to call sync vi.mock('../../src/broker/client.js', () => ({ createFastmailClient: vi.fn().mockResolvedValue({ fetchCalendars: vi.fn().mockResolvedValue([]), }), })) // Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests) vi.mock('../../src/broker/crypto.js', () => ({ decryptPassword: mockDecryptPassword, })) // Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV) const DEFAULT_FORM_PAYLOAD = JSON.stringify({ title: 'Lunch', allDay: false, start: '2026-06-10T12:00:00', end: '2026-06-10T13:00:00', recurrence: 'none', }) const makeRow = (overrides: Record = {}) => ({ id: 1, userId: 42, operation: 'create' as const, status: 'pending' as const, uid: 'test-uid@familysync', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', calendarObjectUrl: null, etag: null, payload: DEFAULT_FORM_PAYLOAD, attemptCount: 0, nextAttemptAt: new Date(Date.now() - 5000), // already due lastError: null, groupId: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }) const makeResponse = (status: number): Response => ({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response // Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW // This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain. function wireMockChain() { mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) mockUpdate.mockReturnValue({ set: mockUpdateSet }) // mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'): // - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) // - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02) // - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status) // JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead. mockFromFn.mockImplementation((table: unknown) => { const tableName = (table as Record)[Symbol.for('drizzle:Name')] ?? '' if (tableName === 'member_credentials') { return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) } } if (tableName === 'calendar_events') { return { where: mockWhereCalEvents } } return { where: mockWherePending } }) mockWhereCalEvents.mockImplementation(() => Promise.resolve([])) mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) mockSelectFn.mockReturnValue({ from: mockFromFn }) // Default: decryptPassword succeeds mockDecryptPassword.mockReturnValue('app-password') } describe('runOutboxDrain — state transitions', () => { beforeEach(() => { vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204)) mockPendingRows = [makeRow()] await runOutboxDrain() // Must update status to 'done' expect(mockUpdate).toHaveBeenCalled() const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } expect(setArg?.status).toBe('done') }) it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412)) mockPendingRows = [makeRow()] await runOutboxDrain() // 412 = hard fail (conflict) — must NOT retry, must mark failed const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string } expect(setArg?.status).toBe('failed') expect(setArg?.lastError).toBeTruthy() }) it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) const row = makeRow({ attemptCount: 0 }) mockPendingRows = [row] const beforeDrain = Date.now() await runOutboxDrain() // Must NOT transition to done or failed — backoff const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string attemptCount?: number nextAttemptAt?: Date } expect(setArg?.status).not.toBe('done') expect(setArg?.status).not.toBe('failed') expect(setArg?.attemptCount).toBe(1) // nextAttemptAt must be in the future expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain) }) it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) // MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead const row = makeRow({ attemptCount: 4 }) mockPendingRows = [row] await runOutboxDrain() const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } expect(setArg?.status).toBe('dead') }) it('does not crash when pending rows list is empty', async () => { mockPendingRows = [] await expect(runOutboxDrain()).resolves.not.toThrow() }) }) describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { beforeEach(() => { vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') let capturedIcsString: unknown = null vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString return makeResponse(201) }) mockPendingRows = [makeRow()] await runOutboxDrain() expect(typeof capturedIcsString).toBe('string') expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true) expect(capturedIcsString).toContain('SUMMARY:Lunch') }) it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js') let capturedIcsString: unknown = null vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { capturedIcsString = icsString return makeResponse(204) }) mockPendingRows = [makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', })] await runOutboxDrain() expect(typeof capturedIcsString).toBe('string') expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true) }) it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => { mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })] await runOutboxDrain() const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } expect(setArg?.status).toBe('failed') }) }) describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('processes the create row BEFORE the delete row when both share a groupId', async () => { const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js') const createResponse = makeResponse(201) const deleteResponse = makeResponse(204) vi.mocked(createCalendarEvent).mockResolvedValue(createResponse) vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse) const groupId = 'edit-move-group-001' // delete row listed first (to verify ordering is enforced regardless of order in the array) const deleteRow = makeRow({ id: 2, operation: 'delete', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', etag: '"etag-old"', payload: null, groupId, }) const createRow = makeRow({ id: 3, operation: 'create', calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/', groupId, }) // Both rows in the pending list mockPendingRows = [deleteRow, createRow] // The durable sibling-status check (CR-04) runs for the delete row with groupId. // It queries calendarOutbox for the sibling create's status. By the time the delete // is processed (create was sorted and dispatched first), we simulate the sibling as 'done'. // The base mockWherePending returns mockPendingRows for all calendarOutbox selects; we // override just the sibling-status call with mockImplementationOnce queued after the // pending-rows select call. mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select await runOutboxDrain() // CREATE must be called before DELETE const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0] const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0] // If either was never called, the test will fail naturally. // If create order index > delete order index, create ran AFTER delete — fail. expect(createCall).toBeLessThan(deleteCall) }) }) describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => { beforeEach(() => { vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) const groupId = 'edit-move-group-001' const deleteRow = makeRow({ id: 2, operation: 'delete', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', etag: '"etag-old"', payload: null, groupId, }) // Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet) // First mockWherePending call → pending-rows select (only the delete row) // Second mockWherePending call → sibling-status select (create is still 'pending') mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }])) await runOutboxDrain() // The delete must NOT have been dispatched — sibling create is not yet done expect(deleteCalendarEvent).not.toHaveBeenCalled() // The delete row's status must NOT have been updated to done or failed const statusCalls = mockUpdateSet.mock.calls.filter((call) => { const arg = call[0] as { status?: string } return arg?.status === 'done' || arg?.status === 'failed' }) expect(statusCalls.length).toBe(0) }) it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) const groupId = 'edit-move-group-001' const deleteRow = makeRow({ id: 2, operation: 'delete', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', etag: '"etag-old"', payload: null, groupId, }) // Drain 2: delete row is pending again, sibling create is now 'done' mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) await runOutboxDrain() expect(deleteCalendarEvent).toHaveBeenCalledTimes(1) }) it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)) const groupId = 'edit-move-group-001' const deleteRow = makeRow({ id: 2, operation: 'delete', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/Old/uid.ics', etag: '"etag-old"', payload: null, groupId, }) // Sibling create is 'failed' — the delete must be permanently skipped mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }])) await runOutboxDrain() // The original event must be preserved — delete must NOT be dispatched expect(deleteCalendarEvent).not.toHaveBeenCalled() // The delete row must be marked failed (permanently, not just skipped this cycle) const failedCall = mockUpdateSet.mock.calls.find((call) => { const arg = call[0] as { status?: string; lastError?: string } return arg?.status === 'failed' && typeof arg?.lastError === 'string' }) expect(failedCall).toBeDefined() const failArg = failedCall![0] as { lastError: string } expect(failArg.lastError).toMatch(/paired create/) }) it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') // Simulate a slow create so the second drain starts while first is still running vi.mocked(createCalendarEvent).mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)), ) mockPendingRows = [makeRow({ id: 1 })] // Start both drains concurrently WITHOUT awaiting the first const drain1 = runOutboxDrain() const drain2 = runOutboxDrain() await Promise.all([drain1, drain2]) // Only one dispatch must have happened — the second drain must have been a no-op expect(createCalendarEvent).toHaveBeenCalledTimes(1) }) }) describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => { beforeEach(() => { // Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce // queues from prior tests do not bleed into subsequent tests via the shared mockWherePending. vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js') let capturedEtag: string | null = null vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { capturedEtag = etag return makeResponse(204) }) const updateRow = makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', etag: 'old-etag', // stale enqueue-time etag }) mockPendingRows = [updateRow] // Mock the calendarEvents etag lookup to return a fresher etag. // In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so // the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED. mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]) await runOutboxDrain() // The PUT must use the freshest etag from calendarEvents, not the stale row.etag expect(capturedEtag).toBe('new-etag') expect(capturedEtag).not.toBe('old-etag') }) it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js') let capturedEtag: string | null = null vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { capturedEtag = etag return makeResponse(204) }) const updateRow = makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', etag: 'fallback-etag', }) mockPendingRows = [updateRow] // mockWhereCalEvents is already configured to return [] by default in wireMockChain. // No row for the uid → worker falls back to row.etag. // In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly). // In GREEN, mockWhereCalEvents returns [] so the fallback is exercised. await runOutboxDrain() // When calendarEvents has no row for the uid, fall back to row.etag expect(capturedEtag).toBe('fallback-etag') }) }) describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => { beforeEach(() => { vi.resetAllMocks() mockPendingRows = [] wireMockChain() }) it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => { // Make decryptPassword throw so loadClientForUser throws mockDecryptPassword.mockImplementation(() => { throw new Error('bad credentials') }) const { createFastmailClient } = await import('../../src/broker/client.js') mockPendingRows = [makeRow()] await runOutboxDrain() // The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it) const updateCalls = mockUpdateSet.mock.calls const anyStatusChange = updateCalls.some((call) => { const arg = call[0] as { status?: string } return arg?.status !== undefined }) expect(anyStatusChange).toBe(false) // createFastmailClient must NEVER be called with empty-string credentials const emptyCalls = vi.mocked(createFastmailClient).mock.calls.filter( ([email, password]) => email === '' || password === '' ) expect(emptyCalls.length).toBe(0) }) it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js') vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) const row = makeRow({ attemptCount: 0 }) mockPendingRows = [row] const beforeDrain = Date.now() await runOutboxDrain() const afterDrain = Date.now() const setArg = mockUpdateSet.mock.calls[0]?.[0] as { nextAttemptAt?: Date; attemptCount?: number } expect(setArg?.attemptCount).toBe(1) // WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15) // Allow ±2s for execution overhead const expectedMinMs = beforeDrain + 14_000 const expectedMaxMs = afterDrain + 16_000 expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs) expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs) }) })