diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index ab1fe8c..b4fab7d 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -18,7 +18,8 @@ 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, assembleRruleString } from '../../src/broker/outboxWorker.js'; +import { runOutboxDrain, assembleRruleString, scheduleOutboxDrain } from '../../src/broker/outboxWorker.js'; +import { signalOutboxDrain } from '../../src/lib/outboxTrigger.js'; // ── Drizzle DB mock ──────────────────────────────────────────────────────── // Follows the pattern from PATTERNS.md §Drizzle DB mock in tests. @@ -734,3 +735,111 @@ describe('FREQ persistence (D-07 regression)', () => { expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY'); }); }); + +// ─── scheduleOutboxDrain trigger-wiring tests (D-09) ───────────────────────── +// These tests verify the trigger-wiring behavior introduced in Plan 09-01: +// SC-1: signalOutboxDrain() → drain fires promptly (no 15s wait) +// D-05: mid-drain signal collapses to exactly one trailing re-drain +// D-07: concurrent scheduleOutboxDrain() calls dispatch each row exactly once +// +// RED: these tests fail because scheduleOutboxDrain is not yet exported. + +describe('scheduleOutboxDrain — trigger wiring (D-09)', () => { + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + // Test A (SC-1): signalOutboxDrain() triggers a drain immediately — no 15s wait. + // With one pending row and createCalendarEvent mocked to 201, calling signalOutboxDrain() + // then flushing microtasks results in createCalendarEvent called exactly once. + // No vi.useFakeTimers() — drain fires via the EventEmitter signal, not the interval. + it('SC-1: signalOutboxDrain() triggers drain promptly — createCalendarEvent called once without advancing timers', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); + mockPendingRows = [makeRow()]; + + signalOutboxDrain(); + + // Flush microtasks so the async drain has a chance to run + await new Promise((resolve) => setImmediate(resolve)); + // One more flush to let the drain's internal async steps complete + await new Promise((resolve) => setImmediate(resolve)); + + expect(createCalendarEvent).toHaveBeenCalledTimes(1); + }); + + // Test B (SC-4 / D-05): a signal arriving during an in-flight drain triggers exactly + // one trailing re-drain — not two, not zero. + it('D-05: two signals mid-drain collapse to exactly one trailing re-drain', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + + // Capture the resolve function so we can hold the first drain in flight + let resolveFirst!: () => void; + const firstDone = new Promise((resolve) => { + resolveFirst = resolve; + }); + + // First call: held until we release it; subsequent calls resolve immediately + vi.mocked(createCalendarEvent) + .mockImplementationOnce(async () => { + await firstDone; + return makeResponse(201); + }) + .mockResolvedValue(makeResponse(201)); + + // Two pending rows: drain 1 picks up row 1, trailing drain picks up row 2 + const row1 = makeRow({ id: 1, uid: 'uid-1@familysync' }); + const row2 = makeRow({ id: 2, uid: 'uid-2@familysync' }); + mockPendingRows = [row1]; + + // Start drain 1 (via signal) — it blocks on firstDone + signalOutboxDrain(); + await new Promise((resolve) => setImmediate(resolve)); + + // While drain 1 is in flight, send two more signals — both should collapse to one trailing drain + mockPendingRows = [row2]; + signalOutboxDrain(); + signalOutboxDrain(); + + // Release drain 1 — this lets it finish, then the trailing re-drain fires + resolveFirst(); + + // Flush: drain 1 finally-block + trailing scheduleOutboxDrain() + trailing drain steps + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + // Drain 1 called createCalendarEvent once (row1); trailing drain called it once (row2). + // Total = 2 — never a third pass. + expect(createCalendarEvent).toHaveBeenCalledTimes(2); + }); + + // Test C (SC-4 / D-07): two concurrent scheduleOutboxDrain() calls dispatch each row + // exactly once — the second call is a no-op via the isDraining guard. + it('D-07: two concurrent scheduleOutboxDrain() calls invoke createCalendarEvent exactly once', async () => { + vi.useFakeTimers(); + try { + const { createCalendarEvent } = await import('../../src/broker/write.js'); + // Slow create so the second scheduleOutboxDrain starts while first is still running + vi.mocked(createCalendarEvent).mockImplementation( + () => + new Promise((resolve) => + setTimeout(() => resolve(makeResponse(201)), 20), + ), + ); + mockPendingRows = [makeRow({ id: 1 })]; + + // Both calls are synchronous — second must no-op via isDraining guard + scheduleOutboxDrain(); + scheduleOutboxDrain(); + + await vi.runAllTimersAsync(); + + expect(createCalendarEvent).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +});