Phase 9: Faster Write-Back (CAL-15) — event-driven outbox drain #14
@@ -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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user