test(03-10): add RED tests for ICS builder wiring + WR-04 + CR-02
- vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases (all-day DTEND+1 fails: emits 20260610 not 20260611) - outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR to CalDAV write functions (fails: raw JSON passes through today) - worker: unparseable payload must mark row failed (fails: marks done today) - Update makeRow default payload to form JSON shape the worker should parse
This commit is contained in:
@@ -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<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
userId: 42,
|
||||
@@ -82,7 +91,7 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
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()
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user