/** * 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) * 6. scheduleOutboxDrain trigger wiring (D-09): signal → prompt drain, trailing re-drain collapse */ import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'; import { runOutboxDrain, assembleRruleString, scheduleOutboxDrain, initOutboxTrigger, stopOutboxTrigger, __resetDrainState, } 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. // // 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, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword, } = vi.hoisted(() => { const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }); // mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status) // db.select().from(calendarOutbox).where(...) — resolves to the row array const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])); // mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02) // db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])); const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending }); const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }); // By default returns a dummy password so loadClientForUser succeeds const mockDecryptPassword = vi.fn().mockReturnValue('app-password'); return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword, }; }); let mockPendingRows: unknown[] = []; // Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where() const FAKE_CRED_ROW = { userId: 42, fastmailEmail: 'test@fastmail.com', encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}', }; 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([]), }), })); // Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests) vi.mock('../../src/broker/crypto.js', () => ({ decryptPassword: mockDecryptPassword, })); // Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV) const DEFAULT_FORM_PAYLOAD = JSON.stringify({ title: 'Lunch', allDay: false, start: '2026-06-10T12:00:00', end: '2026-06-10T13:00:00', recurrence: 'none', }); 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: DEFAULT_FORM_PAYLOAD, 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; // Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW // This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain. function wireMockChain() { mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); mockUpdate.mockReturnValue({ set: mockUpdateSet }); // mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'): // - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) // - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02) // - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status) // JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead. mockFromFn.mockImplementation((table: unknown) => { const tableName = (table as Record)[Symbol.for('drizzle:Name')] ?? ''; if (tableName === 'member_credentials') { return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }; } if (tableName === 'calendar_events') { // CR-02: the freshest-etag re-read now scopes to the writing member's calendar: // .from(calendarEvents).innerJoin(calendars, ...).where(...).limit(1) // mockWhereCalEvents stays the awaited terminal (returned by .limit) so existing // mockWhereCalEvents.mockResolvedValue([{ etag }]) overrides still drive the etag. return { innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ limit: mockWhereCalEvents }), }), }; } return { where: mockWherePending }; }); mockWhereCalEvents.mockImplementation(() => Promise.resolve([])); mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)); mockSelectFn.mockReturnValue({ from: mockFromFn }); // Default: decryptPassword succeeds mockDecryptPassword.mockReturnValue('app-password'); } describe('runOutboxDrain — state transitions', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); 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 — ICS building from form JSON (CR-02)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); mockPendingRows = [makeRow()]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true); expect(capturedIcsString).toContain('SUMMARY:Lunch'); }); it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { capturedIcsString = icsString; return makeResponse(204); }); mockPendingRows = [ makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', }), ]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true); }); it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => { mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })]; await runOutboxDrain(); const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; expect(setArg?.status).toBe('failed'); }); // IN-03 (iteration 2): a JSON-parseable but schema-INVALID payload (e.g. missing the // required title) can never produce a valid VEVENT, so the row is hard-failed (no // retry) rather than dispatched with SUMMARY:undefined. it('IN-03: create row with schema-invalid payload (missing title) is hard-failed, never dispatched', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); // Valid JSON, but title is missing → fails outboxPayloadSchema const badPayload = JSON.stringify({ allDay: false, start: '2026-06-10T12:00:00', end: '2026-06-10T13:00:00', }); mockPendingRows = [makeRow({ payload: badPayload })]; await runOutboxDrain(); // Must NOT have dispatched a CalDAV write with an invalid VEVENT expect(createCalendarEvent).not.toHaveBeenCalled(); const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }; expect(setArg?.status).toBe('failed'); expect(setArg?.lastError).toMatch(/validation/i); }); // CR-01 (iteration 2): the edit-as-move create branch must re-apply the RRULE the // route stashed on the payload as `_preservedRrule`, so a moved recurring series keeps // its RRULE instead of collapsing into a single occurrence. it('CR-01: create row re-applies _preservedRrule → emitted ICS contains RRULE:', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); // Move payload: no explicit `recurrence`, but the route stashed the source RRULE. const movePayload = JSON.stringify({ title: 'Moved weekly standup', allDay: false, start: '2026-06-10T12:00:00', end: '2026-06-10T13:00:00', _preservedRrule: 'FREQ=WEEKLY;BYDAY=MO', }); mockPendingRows = [ makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' }), ]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).toContain('RRULE:'); expect(capturedIcsString as string).toContain('FREQ=WEEKLY'); }); // CR-01 corollary: an explicit `recurrence` on a create still wins over any preserved // RRULE (deliberate user choice); recurrence:'none' must emit no RRULE. it("CR-01: explicit recurrence:'none' wins → emitted ICS has no RRULE even if _preservedRrule present", async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); const payload = JSON.stringify({ title: 'One-off', allDay: false, start: '2026-06-10T12:00:00', end: '2026-06-10T13:00:00', recurrence: 'none', _preservedRrule: 'FREQ=WEEKLY;BYDAY=MO', }); mockPendingRows = [makeRow({ operation: 'create', payload })]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).not.toContain('RRULE:'); }); }); describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); 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]; // The durable sibling-status check (CR-04) runs for the delete row with groupId. // It queries calendarOutbox for the sibling create's status. By the time the delete // is processed (create was sorted and dispatched first), we simulate the sibling as 'done'. // The base mockWherePending returns mockPendingRows for all calendarOutbox selects; we // override just the sibling-status call with mockImplementationOnce queued after the // pending-rows select call. mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); // sibling-status select 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); }); }); describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)); const groupId = 'edit-move-group-001'; 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, }); // Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet) // First mockWherePending call → pending-rows select (only the delete row) // Second mockWherePending call → sibling-status select (create is still 'pending') mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }])); await runOutboxDrain(); // The delete must NOT have been dispatched — sibling create is not yet done expect(deleteCalendarEvent).not.toHaveBeenCalled(); // The delete row's status must NOT have been updated to done or failed const statusCalls = mockUpdateSet.mock.calls.filter((call) => { const arg = call[0] as { status?: string }; return arg?.status === 'done' || arg?.status === 'failed'; }); expect(statusCalls.length).toBe(0); }); it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)); const groupId = 'edit-move-group-001'; 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, }); // Drain 2: delete row is pending again, sibling create is now 'done' mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); await runOutboxDrain(); expect(deleteCalendarEvent).toHaveBeenCalledTimes(1); }); it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => { const { deleteCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204)); const groupId = 'edit-move-group-001'; 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, }); // Sibling create is 'failed' — the delete must be permanently skipped mockWherePending .mockImplementationOnce(() => Promise.resolve([deleteRow])) .mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }])); await runOutboxDrain(); // The original event must be preserved — delete must NOT be dispatched expect(deleteCalendarEvent).not.toHaveBeenCalled(); // The delete row must be marked failed (permanently, not just skipped this cycle) const failedCall = mockUpdateSet.mock.calls.find((call) => { const arg = call[0] as { status?: string; lastError?: string }; return arg?.status === 'failed' && typeof arg?.lastError === 'string'; }); expect(failedCall).toBeDefined(); const failArg = failedCall![0] as { lastError: string }; expect(failArg.lastError).toMatch(/paired create/); }); it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); // Simulate a slow create so the second drain starts while first is still running vi.mocked(createCalendarEvent).mockImplementation( () => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)), ); mockPendingRows = [makeRow({ id: 1 })]; // Start both drains concurrently WITHOUT awaiting the first const drain1 = runOutboxDrain(); const drain2 = runOutboxDrain(); await Promise.all([drain1, drain2]); // Only one dispatch must have happened — the second drain must have been a no-op expect(createCalendarEvent).toHaveBeenCalledTimes(1); }); }); describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => { beforeEach(() => { // Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce // queues from prior tests do not bleed into subsequent tests via the shared mockWherePending. vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedEtag: string | null = null; vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { capturedEtag = etag; return makeResponse(204); }); const updateRow = makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', etag: 'old-etag', // stale enqueue-time etag }); mockPendingRows = [updateRow]; // Mock the calendarEvents etag lookup to return a fresher etag. // In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so // the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED. mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]); await runOutboxDrain(); // The PUT must use the freshest etag from calendarEvents, not the stale row.etag expect(capturedEtag).toBe('new-etag'); expect(capturedEtag).not.toBe('old-etag'); }); it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedEtag: string | null = null; vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => { capturedEtag = etag; return makeResponse(204); }); const updateRow = makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', etag: 'fallback-etag', }); mockPendingRows = [updateRow]; // mockWhereCalEvents is already configured to return [] by default in wireMockChain. // No row for the uid → worker falls back to row.etag. // In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly). // In GREEN, mockWhereCalEvents returns [] so the fallback is exercised. await runOutboxDrain(); // When calendarEvents has no row for the uid, fall back to row.etag expect(capturedEtag).toBe('fallback-etag'); }); }); describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => { // Make decryptPassword throw so loadClientForUser throws mockDecryptPassword.mockImplementation(() => { throw new Error('bad credentials'); }); const { createFastmailClient } = await import('../../src/broker/client.js'); mockPendingRows = [makeRow()]; await runOutboxDrain(); // The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it) const updateCalls = mockUpdateSet.mock.calls; const anyStatusChange = updateCalls.some((call) => { const arg = call[0] as { status?: string }; return arg?.status !== undefined; }); expect(anyStatusChange).toBe(false); // createFastmailClient must NEVER be called with empty-string credentials const emptyCalls = vi .mocked(createFastmailClient) .mock.calls.filter(([email, password]) => email === '' || password === ''); expect(emptyCalls.length).toBe(0); }); it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', 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(); const afterDrain = Date.now(); const setArg = mockUpdateSet.mock.calls[0]?.[0] as { nextAttemptAt?: Date; attemptCount?: number; }; expect(setArg?.attemptCount).toBe(1); // WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15) // Allow ±2s for execution overhead const expectedMinMs = beforeDrain + 14_000; const expectedMaxMs = afterDrain + 16_000; expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs); expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs); }); }); // ─── D-06: assembleRruleString unit tests ───────────────────────────────────── // Exercise the exported `assembleRruleString` helper. describe('assembleRruleString (D-06)', () => { it('returns base preset unchanged when no bound given', () => { expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY'); }); it('appends COUNT when count is given (count wins over until)', () => { expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5'); }); it('COUNT wins when both until and count are provided (mutual exclusion, RFC 5545 §3.3.10)', () => { expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5'); }); it('appends UNTIL as DATE form (YYYYMMDD) for all-day events', () => { expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe( 'FREQ=WEEKLY;UNTIL=20260630', ); }); it('appends UNTIL as DATETIME UTC form (YYYYMMDDTHHMMSSZ) for timed events', () => { expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe( 'FREQ=WEEKLY;UNTIL=20260630T235959Z', ); }); it('COUNT=5 appended to FREQ=DAILY (matches plan behavior assertion)', () => { expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5'); }); }); // ─── D-07: FREQ-persistence regression lock ──────────────────────────────────── // The FREQ-persistence assertion catches the D-07 regression scenario // (recurrenceUntil/recurrenceCount wiring must not drop the FREQ). describe('FREQ persistence (D-07 regression)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('D-07: daily-recurrence payload assembles to FREQ=DAILY (not weekly or none) in emitted ICS', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); const dailyPayload = JSON.stringify({ title: 'Daily standup', allDay: false, start: '2026-06-10T09:00:00', end: '2026-06-10T09:30:00', recurrence: 'daily', }); mockPendingRows = [makeRow({ payload: dailyPayload })]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); // D-07: FREQ must be DAILY — not WEEKLY or absent expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY'); 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 describe('scheduleOutboxDrain — trigger wiring (D-09)', () => { // Wire the EventEmitter signal → scheduleOutboxDrain once for this describe block. // initOutboxTrigger registers the 'drain' listener that connects signalOutboxDrain() // to scheduleOutboxDrain(). Called once (not per-test) to avoid listener accumulation. beforeAll(() => { initOutboxTrigger(); }); // WR-03: remove the leaked 'drain' listener so it cannot fire against the mocked // DB in subsequent describe blocks (fileParallelism:false shares one module instance). afterAll(() => { stopOutboxTrigger(); }); beforeEach(() => { vi.resetAllMocks(); // WR-03 / IN-03: module-level isDraining/drainRequested are not reset by // vi.resetAllMocks(); reset them explicitly so each test starts quiescent // instead of relying on the previous test having drained cleanly. __resetDrainState(); 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. // The trailing re-drain (triggered by drainRequested) finds 0 pending rows so // createCalendarEvent is called exactly once total. 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)), ); const row = makeRow({ id: 1 }); // First pending-rows query returns the row; subsequent queries return empty // (simulates: drain 1 processes and removes the row; trailing drain finds nothing) mockWherePending .mockImplementationOnce(() => Promise.resolve([row])) .mockImplementation(() => Promise.resolve([])); // Both calls are synchronous — second sees isDraining=true and sets drainRequested=true scheduleOutboxDrain(); scheduleOutboxDrain(); await vi.runAllTimersAsync(); // Drain 1 dispatched the row once; trailing drain found 0 rows → createCalendarEvent once total expect(createCalendarEvent).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); }); // ─── Phase 11 Plan 03: reminderLeadMinutes schema + VALARM wiring ─────────────── // CAL-13: reminderLeadMinutes round-trips end-to-end through outbox payload → // buildVeventString → emitted ICS. // CAL-14: UPDATE row with no reminderLeadMinutes in payload preserves existing // VALARM verbatim from rawVevent (mirrors WR-01 _preservedRrule pattern). describe('runOutboxDrain — reminderLeadMinutes VALARM wiring (CAL-13/CAL-14, Phase 11 Plan 03)', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); // CAL-14: UPDATE row with NO reminderLeadMinutes field, but rawVevent has a VALARM → // emitted ICS must still contain BEGIN:VALARM (preserve path, mirrors _preservedRrule WR-01). it('CAL-14 preserve: UPDATE with no reminderLeadMinutes field preserves existing VALARM from rawVevent', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { capturedIcsString = icsString; return makeResponse(204); }); // rawVevent that already has a VALARM (TRIGGER:-PT30M) const rawVeventWithValarm = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:test-uid@familysync', 'SUMMARY:Team meeting', 'DTSTART:20260610T120000Z', 'DTEND:20260610T130000Z', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'TRIGGER:-PT30M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); // Payload has NO reminderLeadMinutes key (absent = no-change, D-08) const updatePayload = JSON.stringify({ title: 'Team meeting', allDay: false, start: '2026-06-10T12:00:00.000Z', end: '2026-06-10T13:00:00.000Z', }); mockPendingRows = [ makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', payload: updatePayload, }), ]; // Simulate freshEtagRows returning rawVevent that has a VALARM mockWhereCalEvents.mockResolvedValue([{ etag: '"fresh"', rawVevent: rawVeventWithValarm }]); await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); // The emitted ICS must contain the preserved VALARM expect(capturedIcsString as string).toContain('BEGIN:VALARM'); expect(capturedIcsString as string).toContain('TRIGGER:-PT30M'); }); // CAL-13: CREATE row with reminderLeadMinutes=15 → emitted ICS contains TRIGGER:-PT15M it('CAL-13 timed: CREATE row with reminderLeadMinutes=15 emits TRIGGER:-PT15M', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); const payload = JSON.stringify({ title: 'Doctor appointment', allDay: false, start: '2026-06-15T14:00:00.000Z', end: '2026-06-15T15:00:00.000Z', reminderLeadMinutes: 15, }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).toContain('BEGIN:VALARM'); expect(capturedIcsString as string).toContain('TRIGGER:-PT15M'); }); // CAL-13 clear: UPDATE row with reminderLeadMinutes=null → emitted ICS has no VALARM it('CAL-13 clear: UPDATE row with reminderLeadMinutes=null emits no VALARM (explicit clear)', async () => { const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { capturedIcsString = icsString; return makeResponse(204); }); const updatePayload = JSON.stringify({ title: 'No reminder event', allDay: false, start: '2026-06-15T14:00:00.000Z', end: '2026-06-15T15:00:00.000Z', reminderLeadMinutes: null, }); mockPendingRows = [ makeRow({ operation: 'update', calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', payload: updatePayload, }), ]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).not.toContain('BEGIN:VALARM'); }); // CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 → // emitted ICS contains VALUE=DATE-TIME absolute trigger (not DURATION trigger). it('CAL-13 all-day: CREATE row with allDay=true and reminderLeadMinutes=1440 emits VALUE=DATE-TIME trigger', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); const payload = JSON.stringify({ title: 'Birthday party', allDay: true, start: '2026-06-20', end: '2026-06-20', reminderLeadMinutes: 1440, // 1 day before = leadDays = 1440/1440 = 1 }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).toContain('BEGIN:VALARM'); // Must use VALUE=DATE-TIME absolute trigger for all-day (not DURATION) expect(capturedIcsString as string).toContain('VALUE=DATE-TIME'); }); }); // ─── Phase 11 Plan 05 WR-02: .max(10080) on reminderLeadMinutes in outboxPayloadSchema ── // A payload with reminderLeadMinutes=10081 exceeds the 1-week UI cap (10080 min). // The outboxPayloadSchema must reject it so the row is hard-failed rather than // letting an out-of-range value silently flow into the VALARM trigger. describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayloadSchema', () => { beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); it('WR-02: payload with reminderLeadMinutes=10081 is hard-failed (validation error)', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); const payload = JSON.stringify({ title: 'Over-cap reminder', allDay: false, start: '2026-12-15T10:00:00Z', end: '2026-12-15T11:00:00Z', reminderLeadMinutes: 10081, // 1 min over the 1-week cap }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); // Must NOT dispatch to CalDAV — validation must fire before ICS assembly expect(createCalendarEvent).not.toHaveBeenCalled(); const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }; expect(setArg?.status).toBe('failed'); expect(setArg?.lastError).toMatch(/validation/i); }); it('WR-02: payload with reminderLeadMinutes=10080 (boundary) passes validation and dispatches', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); const payload = JSON.stringify({ title: 'Max-cap reminder', allDay: false, start: '2026-12-15T10:00:00Z', end: '2026-12-15T11:00:00Z', reminderLeadMinutes: 10080, // exactly 1 week — must be allowed }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); // Row is valid — CalDAV write must have been dispatched expect(createCalendarEvent).toHaveBeenCalledTimes(1); const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; expect(setArg?.status).toBe('done'); }); it('WR-02: payload with reminderLeadMinutes=null (explicit clear) passes validation', async () => { const { createCalendarEvent } = await import('../../src/broker/write.js'); vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201)); const payload = JSON.stringify({ title: 'Clear reminder', allDay: false, start: '2026-12-15T10:00:00Z', end: '2026-12-15T11:00:00Z', reminderLeadMinutes: null, }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); // null (explicit clear) must pass .nullable() const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }; expect(setArg?.status).not.toBe('failed'); }); }); // ─── Plan 18-03: stored household_timezone drives all-day alert instant ─────── // // D-05: when getHouseholdTimezone(db) returns 'America/Chicago', the CREATE and // UPDATE all-day branches must compute the VALARM TRIGGER using Chicago // local time, not process.env.TZ. // D-06: existing tests that do not mock app_config continue to fall back to the // current process.env.TZ / Intl behavior (backward-compat path). // // These tests FAIL before the rewire (GREEN) because the code still reads // process.env.TZ at both sites. describe('runOutboxDrain — Plan 18-03: stored household_timezone drives all-day alert (D-05)', () => { // Helper: wire the full db mock chain including app_config for getHouseholdTimezone. // The app_config SELECT chain: select({value}).from(appConfig).where(...).limit(1) // → from(app_config) → { where: fn → limitFn } → limitFn() → [{value}] function wireMockChainWithTz(storedTz: string | null) { wireMockChain(); // base wiring for outbox/credentials/events // Extend mockFromFn to also handle app_config table with where().limit() chain const baseMockFromFn = mockFromFn.getMockImplementation(); mockFromFn.mockImplementation((table: unknown) => { const tableName = (table as Record)[Symbol.for('drizzle:Name')] ?? ''; if (tableName === 'app_config') { const tzRows = storedTz ? [{ value: storedTz }] : []; const limitResolve = vi.fn().mockResolvedValue(tzRows); // .where() must return an object with .limit(), not the fn itself return { where: vi.fn().mockReturnValue({ limit: limitResolve }) }; } return baseMockFromFn ? baseMockFromFn(table) : { where: mockWherePending }; }); } beforeEach(() => { vi.resetAllMocks(); mockPendingRows = []; wireMockChain(); }); // Event date: 2026-06-20 (summer). Lead = 1440 min = 1 day → alert date = 2026-06-19. // America/Chicago CDT (UTC-5): 9 AM CDT on 2026-06-19 = 2026-06-19T14:00:00Z. // process.env.TZ is unset (UTC in test environment): 9 AM UTC on 2026-06-19 = 2026-06-19T09:00:00Z. // When stored zone drives computation: TRIGGER must contain the Chicago time (14:00 UTC). it('D-05 create branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => { wireMockChainWithTz('America/Chicago'); const { createCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { capturedIcsString = icsString; return makeResponse(201); }); // all-day CREATE payload with 1440-min lead (1 day before) const payload = JSON.stringify({ title: 'Stored-TZ Birthday', allDay: true, start: '2026-06-20', end: '2026-06-20', reminderLeadMinutes: 1440, }); mockPendingRows = [makeRow({ payload })]; await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).toContain('BEGIN:VALARM'); // With stored 'America/Chicago' (CDT, UTC-5): alert date = 2026-06-19, 9 AM CDT = 14:00 UTC. // The absolute TRIGGER value must be 20260619T140000Z (Chicago time). expect(capturedIcsString as string).toContain('20260619T140000Z'); }); it('D-05 update branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => { wireMockChainWithTz('America/Chicago'); const { updateCalendarEvent } = await import('../../src/broker/write.js'); let capturedIcsString: unknown = null; vi.mocked(updateCalendarEvent).mockImplementation( async (_client, _calObjectUrl, icsString, _etag) => { capturedIcsString = icsString; return makeResponse(204); }, ); // all-day UPDATE payload with 1440-min lead — must also read stored TZ const payload = JSON.stringify({ title: 'Stored-TZ Update', allDay: true, start: '2026-06-20', end: '2026-06-20', reminderLeadMinutes: 1440, }); mockPendingRows = [makeRow({ operation: 'update', calendarObjectUrl: 'https://example.com/event.ics', etag: 'W/"abc"', payload })]; // Provide etag so WR-02 re-read path resolves (no rawVevent → falls through to all-day branch) mockWhereCalEvents.mockResolvedValue([{ etag: 'W/"abc"' }]); await runOutboxDrain(); expect(typeof capturedIcsString).toBe('string'); expect(capturedIcsString as string).toContain('BEGIN:VALARM'); // Same expectation: alert date = 2026-06-19, 9 AM CDT = 14:00 UTC. expect(capturedIcsString as string).toContain('20260619T140000Z'); }); });