style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -13,12 +13,12 @@
|
||||
* They will turn GREEN in Plan 03-03 when the implementation is added.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
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 } from '../../src/broker/outboxWorker.js';
|
||||
|
||||
// ── Drizzle DB mock ────────────────────────────────────────────────────────
|
||||
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests.
|
||||
@@ -37,60 +37,68 @@ const {
|
||||
mockSelectFn,
|
||||
mockDecryptPassword,
|
||||
} = vi.hoisted(() => {
|
||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
||||
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[]))
|
||||
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 })
|
||||
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 }
|
||||
})
|
||||
const mockDecryptPassword = vi.fn().mockReturnValue('app-password');
|
||||
return {
|
||||
mockUpdateSet,
|
||||
mockUpdate,
|
||||
mockWherePending,
|
||||
mockWhereCalEvents,
|
||||
mockFromFn,
|
||||
mockSelectFn,
|
||||
mockDecryptPassword,
|
||||
};
|
||||
});
|
||||
|
||||
let mockPendingRows: unknown[] = []
|
||||
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({
|
||||
@@ -99,7 +107,7 @@ const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
||||
start: '2026-06-10T12:00:00',
|
||||
end: '2026-06-10T13:00:00',
|
||||
recurrence: 'none',
|
||||
})
|
||||
});
|
||||
|
||||
const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
@@ -118,25 +126,25 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
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
|
||||
({ 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 })
|
||||
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')] ?? ''
|
||||
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? '';
|
||||
if (tableName === 'member_credentials') {
|
||||
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
|
||||
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:
|
||||
@@ -147,174 +155,176 @@ function wireMockChain() {
|
||||
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 })
|
||||
return { where: mockWherePending };
|
||||
});
|
||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
|
||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows));
|
||||
mockSelectFn.mockReturnValue({ from: mockFromFn });
|
||||
// Default: decryptPassword succeeds
|
||||
mockDecryptPassword.mockReturnValue('app-password')
|
||||
mockDecryptPassword.mockReturnValue('app-password');
|
||||
}
|
||||
|
||||
describe('runOutboxDrain — state transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
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()]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
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')
|
||||
})
|
||||
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()]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412));
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
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()
|
||||
})
|
||||
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 { 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 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)
|
||||
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)
|
||||
})
|
||||
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))
|
||||
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]
|
||||
const row = makeRow({ attemptCount: 4 });
|
||||
mockPendingRows = [row];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
||||
expect(setArg?.status).toBe('dead')
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
mockPendingRows = [];
|
||||
await expect(runOutboxDrain()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
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
|
||||
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()]
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
||||
expect(capturedIcsString).toContain('SUMMARY:Lunch')
|
||||
})
|
||||
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
|
||||
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',
|
||||
})]
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(204);
|
||||
});
|
||||
mockPendingRows = [
|
||||
makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
}),
|
||||
];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
||||
})
|
||||
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{{{' })]
|
||||
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
||||
expect(setArg?.status).toBe('failed')
|
||||
})
|
||||
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))
|
||||
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 })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ payload: badPayload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
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)
|
||||
})
|
||||
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
|
||||
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)
|
||||
})
|
||||
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',
|
||||
@@ -322,25 +332,27 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
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' })]
|
||||
});
|
||||
mockPendingRows = [
|
||||
makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' }),
|
||||
];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect(capturedIcsString as string).toContain('RRULE:')
|
||||
expect(capturedIcsString as string).toContain('FREQ=WEEKLY')
|
||||
})
|
||||
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
|
||||
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)
|
||||
})
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
title: 'One-off',
|
||||
allDay: false,
|
||||
@@ -348,31 +360,31 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
end: '2026-06-10T13:00:00',
|
||||
recurrence: 'none',
|
||||
_preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
})
|
||||
mockPendingRows = [makeRow({ operation: 'create', payload })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ operation: 'create', payload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect(capturedIcsString as string).not.toContain('RRULE:')
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
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 { 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'
|
||||
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,
|
||||
@@ -381,16 +393,16 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
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]
|
||||
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
|
||||
@@ -400,32 +412,32 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
// pending-rows select call.
|
||||
mockWherePending
|
||||
.mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); // sibling-status select
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// CREATE must be called before DELETE
|
||||
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0]
|
||||
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0]
|
||||
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)
|
||||
})
|
||||
})
|
||||
expect(createCall).toBeLessThan(deleteCall);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
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 { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -433,33 +445,33 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
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' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The delete must NOT have been dispatched — sibling create is not yet done
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled()
|
||||
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)
|
||||
})
|
||||
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 { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -467,23 +479,23 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
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' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(deleteCalendarEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
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 { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -491,162 +503,167 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
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' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The original event must be preserved — delete must NOT be dispatched
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled()
|
||||
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/)
|
||||
})
|
||||
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')
|
||||
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 })]
|
||||
mockPendingRows = [makeRow({ id: 1 })];
|
||||
|
||||
// Start both drains concurrently WITHOUT awaiting the first
|
||||
const drain1 = runOutboxDrain()
|
||||
const drain2 = runOutboxDrain()
|
||||
await Promise.all([drain1, drain2])
|
||||
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)
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
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
|
||||
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)
|
||||
})
|
||||
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]
|
||||
});
|
||||
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' }])
|
||||
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]);
|
||||
|
||||
await runOutboxDrain()
|
||||
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')
|
||||
})
|
||||
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
|
||||
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)
|
||||
})
|
||||
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]
|
||||
});
|
||||
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()
|
||||
await runOutboxDrain();
|
||||
|
||||
// When calendarEvents has no row for the uid, fall back to row.etag
|
||||
expect(capturedEtag).toBe('fallback-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()
|
||||
})
|
||||
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') })
|
||||
mockDecryptPassword.mockImplementation(() => {
|
||||
throw new Error('bad credentials');
|
||||
});
|
||||
|
||||
const { createFastmailClient } = await import('../../src/broker/client.js')
|
||||
mockPendingRows = [makeRow()]
|
||||
const { createFastmailClient } = await import('../../src/broker/client.js');
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
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 updateCalls = mockUpdateSet.mock.calls;
|
||||
const anyStatusChange = updateCalls.some((call) => {
|
||||
const arg = call[0] as { status?: string }
|
||||
return arg?.status !== undefined
|
||||
})
|
||||
expect(anyStatusChange).toBe(false)
|
||||
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)
|
||||
})
|
||||
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 { 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 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)
|
||||
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)
|
||||
})
|
||||
})
|
||||
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 ─────────────────────────────────────
|
||||
// These tests import the NOT-YET-EXPORTED `assembleRruleString` helper.
|
||||
@@ -654,29 +671,33 @@ describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff in
|
||||
|
||||
describe('assembleRruleString (D-06)', () => {
|
||||
it('returns base preset unchanged when no bound given', () => {
|
||||
expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D-07: FREQ-persistence regression lock ────────────────────────────────────
|
||||
// RED: will fail because the outbox worker does not yet wire recurrenceUntil/recurrenceCount
|
||||
@@ -684,32 +705,32 @@ describe('assembleRruleString (D-06)', () => {
|
||||
|
||||
describe('FREQ persistence (D-07 regression)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
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
|
||||
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)
|
||||
})
|
||||
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 })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ payload: dailyPayload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
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')
|
||||
})
|
||||
})
|
||||
expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY');
|
||||
expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user