/** * 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. const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) let mockPendingRows: unknown[] = [] const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve(mockPendingRows)) const mockLimitFn = vi.fn().mockReturnValue({ where: mockWherePending }) const mockFromFn = vi.fn().mockReturnValue({ where: mockLimitFn }) const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }) 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 = {}) => ({ 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 mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) mockUpdate.mockReturnValue({ set: mockUpdateSet }) mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) mockLimitFn.mockReturnValue({ where: mockWherePending }) mockFromFn.mockReturnValue({ where: mockLimitFn }) 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)) mockLimitFn.mockReturnValue({ where: mockWherePending }) mockFromFn.mockReturnValue({ where: mockLimitFn }) 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) }) })