Files
familysync/apps/api/tests/broker/outboxWorker.test.ts
T
Lucas Berger 94daca3c7a test(18-03): add failing stored-TZ all-day tests for scheduler + outbox
- reminderScheduler: new describe block with mockThreeQueries helper that
  extends mockTwoQueries to mock getHouseholdTimezone app_config SELECT
  (select({value}).from(appConfig).where(...).limit(1) chain)
- reminderScheduler: D-05 test expects dispatch at 14:00 UTC (Chicago CDT)
  when stored zone is America/Chicago; fails RED (code still reads process.env.TZ=America/New_York)
- reminderScheduler: D-05 NOT-fire test expects no dispatch at 13:00 UTC (NY time)
  when stored zone overrides to Chicago; fails RED (code fires at NY time)
- outboxWorker: new describe block with wireMockChainWithTz that extends
  mockFromFn to handle app_config table via where().limit() chain
- outboxWorker: D-05 create-branch test expects VALARM TRIGGER 20260619T140000Z
  (Chicago CDT); fails RED (code emits 20260619T130000Z using UTC fallback)
- outboxWorker: D-05 update-branch test same assertion, also fails RED
- Existing process.env.TZ-pinned all-day tests untouched; all 72 pass
2026-06-14 22:24:25 -04:00

1203 lines
49 KiB
TypeScript

/**
* 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<string, unknown> = {}) => ({
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, string>)[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<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.
// 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, string>)[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');
});
});