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()
This commit is contained in:
Lucas Berger
2026-06-05 18:20:18 -04:00
parent 95864e5dc8
commit cd4a8931e5
2 changed files with 390 additions and 11 deletions
+25 -11
View File
@@ -22,15 +22,30 @@ 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 = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
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[] = []
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: {
@@ -84,12 +99,12 @@ describe('runOutboxDrain — state transitions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPendingRows = []
// Restore mock chain after clearAllMocks
// 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))
mockLimitFn.mockReturnValue({ where: mockWherePending })
mockFromFn.mockReturnValue({ where: mockLimitFn })
mockFromFn.mockReturnValue({ where: mockWherePending })
mockSelectFn.mockReturnValue({ from: mockFromFn })
})
@@ -167,8 +182,7 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
mockUpdate.mockReturnValue({ set: mockUpdateSet })
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
mockLimitFn.mockReturnValue({ where: mockWherePending })
mockFromFn.mockReturnValue({ where: mockLimitFn })
mockFromFn.mockReturnValue({ where: mockWherePending })
mockSelectFn.mockReturnValue({ from: mockFromFn })
})