diff --git a/apps/api/src/broker/sync.ts b/apps/api/src/broker/sync.ts index 0613cb2..66e33a3 100644 --- a/apps/api/src/broker/sync.ts +++ b/apps/api/src/broker/sync.ts @@ -89,6 +89,10 @@ export async function syncCalendar( // D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column const allDay: boolean = dtstart?.isDate ?? false + // Determine if this event is a recurring master (has RRULE or RDATE). + // Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties. + const isRecurring: boolean = new ICAL.Event(vevent).isRecurring() + // dtstartDate: Drizzle's `date` column accepts a Date object or null. // We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so // Drizzle serialises it correctly as a DATE without a time component. @@ -106,6 +110,7 @@ export async function syncCalendar( dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, + hasRrule: isRecurring, }) .onDuplicateKeyUpdate({ set: { @@ -114,6 +119,7 @@ export async function syncCalendar( dtstartUtc: dtstartUtcValue, dtstartDate: dtstartDateValue, allDay, + hasRrule: isRecurring, updatedAt: new Date(), }, }) diff --git a/apps/api/tests/broker/sync.test.ts b/apps/api/tests/broker/sync.test.ts index 5e406c2..3bfc666 100644 --- a/apps/api/tests/broker/sync.test.ts +++ b/apps/api/tests/broker/sync.test.ts @@ -14,6 +14,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SAMPLE_VEVENT_TIMED, SAMPLE_VEVENT_ALLDAY, + SAMPLE_VEVENT_RECURRING_TIMED, + SAMPLE_VEVENT_RECURRING_ALLDAY, } from '../helpers/db.js' // Track calls for assertions @@ -157,6 +159,94 @@ describe('syncCalendar', () => { expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED) }) + it('sets hasRrule=true for a VEVENT with RRULE (timed recurring)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js') + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' }, + ]), + } + const mockDavCal = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', + syncToken: null, + } + + await syncCalendar(mockClient as never, mockDavCal as never, 1) + + expect(mockInsert).toHaveBeenCalledTimes(2) + const eventValuesArg = mockValues.mock.calls[1][0] + expect(eventValuesArg.hasRrule).toBe(true) + }) + + it('sets hasRrule=true for an all-day VEVENT with RRULE (all-day recurring)', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js') + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: SAMPLE_VEVENT_RECURRING_ALLDAY, etag: '"etag-rrule-allday"', url: '/rrule-allday.ics' }, + ]), + } + const mockDavCal = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', + syncToken: null, + } + + await syncCalendar(mockClient as never, mockDavCal as never, 1) + + expect(mockInsert).toHaveBeenCalledTimes(2) + const eventValuesArg = mockValues.mock.calls[1][0] + expect(eventValuesArg.hasRrule).toBe(true) + }) + + it('sets hasRrule=false for a non-recurring timed VEVENT', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js') + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' }, + ]), + } + const mockDavCal = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v1', + syncToken: null, + } + + await syncCalendar(mockClient as never, mockDavCal as never, 1) + + expect(mockInsert).toHaveBeenCalledTimes(2) + const eventValuesArg = mockValues.mock.calls[1][0] + expect(eventValuesArg.hasRrule).toBe(false) + }) + + it('includes hasRrule in onDuplicateKeyUpdate set so re-syncs self-heal the flag', async () => { + const { syncCalendar } = await import('../../src/broker/sync.js') + + const mockClient = { + fetchCalendarObjects: vi.fn().mockResolvedValue([ + { data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' }, + ]), + } + const mockDavCal = { + url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', + displayName: 'Test Calendar', + ctag: 'ctag-v2', + syncToken: null, + } + + await syncCalendar(mockClient as never, mockDavCal as never, 1) + + // The second onDuplicateKeyUpdate call is for the event upsert + const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0] + expect(eventUpdateArg.set).toHaveProperty('hasRrule', true) + }) + it('updates the calendar ctag/syncToken after a successful sync', async () => { const { syncCalendar } = await import('../../src/broker/sync.js') diff --git a/apps/api/tests/helpers/db.ts b/apps/api/tests/helpers/db.ts index 211da8f..b7e2933 100644 --- a/apps/api/tests/helpers/db.ts +++ b/apps/api/tests/helpers/db.ts @@ -57,3 +57,34 @@ DTEND;VALUE=DATE:20260616 SUMMARY:Test All-Day Event END:VEVENT END:VCALENDAR` + +/** + * Sample VEVENT string — a timed recurring event (RRULE:FREQ=WEEKLY). + * Used to test that sync sets hasRrule=true for recurring events. + */ +export const SAMPLE_VEVENT_RECURRING_TIMED = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//FamilySync//Test//EN +BEGIN:VEVENT +UID:test-recurring-timed-001@familysync +DTSTART:20240101T100000Z +RRULE:FREQ=WEEKLY;BYDAY=MO +SUMMARY:Weekly Monday Meeting +END:VEVENT +END:VCALENDAR` + +/** + * Sample VEVENT string — an all-day recurring event (RRULE:FREQ=YEARLY). + * Used to test that sync sets hasRrule=true for all-day recurring events, + * and that the events route returns occurrences even when dtstartUtc is NULL. + */ +export const SAMPLE_VEVENT_RECURRING_ALLDAY = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//FamilySync//Test//EN +BEGIN:VEVENT +UID:test-recurring-allday-001@familysync +DTSTART;VALUE=DATE:20240615 +RRULE:FREQ=YEARLY +SUMMARY:Annual Birthday +END:VEVENT +END:VCALENDAR`