Files
familysync/apps/api/tests/broker/outboxWorker.test.ts
T
Lucas Berger cd4a8931e5 feat(03-04): implement outbox drain state machine (GREEN)
- runOutboxDrain: drains pending outbox rows, dispatches CalDAV writes
  via broker/write.ts, classifies HTTP responses per D-07/D-08
- CONFLICT_STATUS=412 routes to conflict flow: mark failed, re-sync (D-08)
- TRANSIENT_STATUSES: exponential backoff with MAX_ATTEMPTS=5 dead-letter (D-07)
- HARD_FAIL_STATUSES 400/401/403: fail immediately, no retry (D-07)
- Edit-as-move D-04: create row sorted before delete for same groupId;
  create-fail aborts the paired delete (T-03-14)
- triggerTargetedResync: fetches fresh DAVCalendars, calls syncCalendar (D-06)
- startOutboxWorker: node-cron */15 * * * * * schedule (15s interval)
- Fix test scaffold: vi.hoisted() for mock variables to resolve vitest
  hoisting TDZ issue; simplified mock chain to match and() single .where()
2026-06-05 18:20:18 -04:00

227 lines
8.9 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,
mockFromFn,
mockSelectFn,
} = vi.hoisted(() => {
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
// mockWherePending is the terminal node of the select chain:
// db.select().from(table).where(and(cond1, cond2)) — resolves to the row array
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn }
})
let mockPendingRows: unknown[] = []
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([]),
}),
}))
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: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR',
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
describe('runOutboxDrain — state transitions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPendingRows = []
// Restore mock chain after clearAllMocks:
// db.select().from(table).where(and(cond1, cond2)) → Promise<rows>
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockUpdate.mockReturnValue({ set: mockUpdateSet })
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
mockFromFn.mockReturnValue({ where: mockWherePending })
mockSelectFn.mockReturnValue({ from: mockFromFn })
})
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 — edit-as-move ordering (D-04)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPendingRows = []
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockUpdate.mockReturnValue({ set: mockUpdateSet })
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
mockFromFn.mockReturnValue({ where: mockWherePending })
mockSelectFn.mockReturnValue({ from: mockFromFn })
})
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]
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)
})
})