Files
familysync/apps/api/tests/broker/outboxWorker.test.ts
T
Lucas Berger 7a48659cae fix(03): update event lookup test mocks for CR-01/CR-02 query-chain changes
The CR-01 fix appended .orderBy().limit(1) to the edit/delete event lookups
and CR-02 added .innerJoin(calendars).limit(1) to the freshest-etag re-read.
The existing test doubles terminated the mock chain at .where(), so the new
chain calls hit undefined methods → handlers caught the throw and returned 503
(events.test.ts) and the worker skipped the PUT (outboxWorker.test.ts).

Extend the mocks to match the corrected production chains. Behaviour-preserving:
mockWhereCalEvents stays the awaited terminal so etag override assertions still drive.

8 failing tests now green; full suite: api 103, pwa 141.
2026-06-09 10:51:10 -04:00

575 lines
23 KiB
TypeScript

/**
* 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<string, unknown> = {}) => ({
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, string>)[Symbol.for('drizzle:Name')] ?? ''
if (tableName === 'member_credentials') {
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
}
if (tableName === 'calendar_events') {
// CR-02: the freshest-etag re-read now scopes to the writing member's calendar:
// .from(calendarEvents).innerJoin(calendars, ...).where(...).limit(1)
// mockWhereCalEvents stays the awaited terminal (returned by .limit) so existing
// mockWhereCalEvents.mockResolvedValue([{ etag }]) overrides still drive the etag.
return {
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: 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)
})
})