test(09-01): add failing trigger-wiring tests for SC-1, D-05, D-07

- Import scheduleOutboxDrain (not yet exported — causes RED)
- Import signalOutboxDrain from outboxTrigger.ts
- Add describe block 'scheduleOutboxDrain — trigger wiring (D-09)' with 3 tests:
  Test A SC-1: signalOutboxDrain() fires drain promptly without timer advance
  Test B D-05: two mid-drain signals collapse to exactly one trailing re-drain
  Test C D-07: concurrent scheduleOutboxDrain() calls dispatch exactly once via isDraining guard
- 27 pre-existing tests unmodified and passing; 3 new tests failing (RED)
This commit is contained in:
Lucas Berger
2026-06-12 16:48:41 -04:00
parent 1e12d702a1
commit bcde073729
+110 -1
View File
@@ -18,7 +18,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// This import fails (RED) — broker/outboxWorker.ts does not exist yet. // This import fails (RED) — broker/outboxWorker.ts does not exist yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore intentional RED import // @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 ──────────────────────────────────────────────────────── // ── Drizzle DB mock ────────────────────────────────────────────────────────
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests. // 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'); 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<void>((resolve) => setImmediate(resolve));
// One more flush to let the drain's internal async steps complete
await new Promise<void>((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<void>((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<void>((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<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((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();
}
});
});