diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 2ca3f62..bef65ae 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -73,6 +73,15 @@ vi.mock('../../src/broker/client.js', () => ({ }), })) +// 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, @@ -82,7 +91,7 @@ const makeRow = (overrides: Record = {}) => ({ calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', calendarObjectUrl: null, etag: null, - payload: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR', + payload: DEFAULT_FORM_PAYLOAD, attemptCount: 0, nextAttemptAt: new Date(Date.now() - 5000), // already due lastError: null, @@ -175,6 +184,61 @@ describe('runOutboxDrain — state transitions', () => { }) }) +describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockUpdate.mockReturnValue({ set: mockUpdateSet }) + mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) + mockFromFn.mockReturnValue({ where: mockWherePending }) + mockSelectFn.mockReturnValue({ from: mockFromFn }) + }) + + 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') + }) +}) + describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 81b85b9..52d608b 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -116,3 +116,46 @@ describe('buildVeventString', () => { expect(result.uid.length).toBeGreaterThan(10) }) }) + +describe('buildVeventString — D-13 form-parsed contract', () => { + it('timed event: produces BEGIN:VCALENDAR, SUMMARY, UID, timed DTSTART (Z suffix), and DTEND', () => { + // Simulates the field shape parsed by the worker from the stored form JSON + const result = buildVeventString({ + uid: 'u1@familysync', + summary: 'Lunch', + allDay: false, + dtstart: new Date('2026-06-10T12:00:00Z'), + dtend: new Date('2026-06-10T13:00:00Z'), + }) + + expect(result.icsString).toContain('BEGIN:VCALENDAR') + expect(result.icsString).toContain('SUMMARY:Lunch') + expect(result.icsString).toContain('UID:u1@familysync') + // D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID + expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/) + // DTEND must be present + expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/) + }) + + it('single-day all-day event: DTSTART is DATE format and DTEND = DTSTART + 1 day (RFC-5545 exclusive end, WR-04)', () => { + // Simulates a single-day all-day event where start and end are the same calendar day. + // WR-04: the outbox worker passes the user-entered inclusive end; vevent.ts must advance it. + const result = buildVeventString({ + summary: 'Birthday', + allDay: true, + dtstart: '2026-06-10', + dtend: '2026-06-10', + }) + + // DTSTART must be DATE format (no time component, no TZID) — D-13 all-day contract + expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/) + expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/) + + // WR-04: DTEND must be DTSTART + 1 day (RFC-5545 exclusive end) + expect(result.icsString).toMatch(/DTEND[^:]*:20260611/) + // DTEND date string must NOT equal DTSTART date string (owning-boundary assertion) + const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/) + const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/) + expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]) + }) +})