/** * Broker: syncCalendar event upsert + all-day handling * * Tests syncCalendar in src/broker/sync.ts. * Key behaviors verified (D-13, T-03-05): * - All-day events: dtstart_date (DATE) set, dtstart_utc NULL, allDay=true * - Timed events: dtstart_utc (TIMESTAMP UTC) set, dtstart_date NULL, allDay=false * - UID used as idempotency key: second sync of same UID is an upsert, not duplicate * - Raw VEVENT blob stored verbatim * - Calendar ctag/syncToken updated after sync */ 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 const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }]); const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate }); const mockInsert = vi.fn().mockReturnValue({ values: mockValues }); const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }]); const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }); const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }); const mockSelect = vi.fn().mockReturnValue({ from: mockFrom }); // Prune chain: db.delete(calendarEvents).where(...) const mockDeleteWhere = vi.fn().mockResolvedValue([]); const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere }); // Mock the db singleton at module level (Vitest hoisting) vi.mock('../../src/db/client.js', () => ({ db: { insert: mockInsert, select: mockSelect, delete: mockDelete, }, })); describe('syncCalendar', () => { beforeEach(() => { vi.clearAllMocks(); // Reset mock implementations after clearAllMocks mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]); mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate }); mockInsert.mockReturnValue({ values: mockValues }); mockLimit.mockResolvedValue([{ id: 42 }]); mockWhere.mockReturnValue({ limit: mockLimit }); mockFrom.mockReturnValue({ where: mockWhere }); mockSelect.mockReturnValue({ from: mockFrom }); mockDeleteWhere.mockResolvedValue([]); mockDelete.mockReturnValue({ where: mockDeleteWhere }); }); it('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/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); // insert called twice: calendars + calendarEvents expect(mockInsert).toHaveBeenCalledTimes(2); // Event insert: second call's values arg const eventValuesArg = mockValues.mock.calls[1][0]; expect(eventValuesArg.allDay).toBe(true); expect(eventValuesArg.dtstartDate).toBeTruthy(); expect(eventValuesArg.dtstartUtc).toBeNull(); }); it('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.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.allDay).toBe(false); expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date); expect(eventValuesArg.dtstartDate).toBeNull(); }); it('sets allDay=true for all-day events, allDay=false for timed', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' }]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Test/', displayName: 'Test', ctag: null, syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 1); const eventArg = mockValues.mock.calls[1][0]; expect(eventArg.allDay).toBe(true); }); it('upserts on duplicate UID within the same calendar (onDuplicateKeyUpdate called for events)', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' }]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test', ctag: 'v2', syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 1); // onDuplicateKeyUpdate must be called for both the calendar upsert and the event upsert expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2); }); it('stores the raw VEVENT blob in rawVevent column', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' }]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test', ctag: 'v1', syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 1); const eventArg = mockValues.mock.calls[1][0]; 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('BUG B: scopes the calendar-row select to (userId, url), not url alone', async () => { // Capture the predicate passed to db.select().from(calendars).where(...).limit(1). const capturedWhere: unknown[] = []; mockWhere.mockImplementation((pred: unknown) => { capturedWhere.push(pred); return { limit: mockLimit }; }); const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test', ctag: 'v1', syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 99); expect(capturedWhere.length).toBeGreaterThan(0); const serialized = JSON.stringify(capturedWhere[0], (_k, v) => typeof v === 'object' && v !== null && 'name' in (v as Record) ? (v as { name?: unknown }).name : v, ); expect(serialized).toContain('user_id'); expect(serialized).toContain('url'); }); it('BUG B: calendar upsert is idempotent — onDuplicateKeyUpdate fires for the calendar row', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test', ctag: 'v1', syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 1); // The calendar insert (call 0) must use onDuplicateKeyUpdate so the (userId,url) // unique key makes re-polls update-in-place instead of inserting duplicate rows. expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled(); const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0]; expect(calUpdateArg.set).toHaveProperty('ctag'); }); it('updates the calendar ctag/syncToken after a successful sync', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test Calendar', ctag: 'new-ctag-123', syncToken: 'sync-token-abc', }; await syncCalendar(mockClient as never, mockDavCal as never, 1); // Calendar insert values must include the new ctag and syncToken const calValuesArg = mockValues.mock.calls[0][0]; expect(calValuesArg.ctag).toBe('new-ctag-123'); expect(calValuesArg.syncToken).toBe('sync-token-abc'); }); // Regression: deletes must be reconciled out of the cache. Before this fix, // syncCalendar only upserted present events, so a deleted event lingered in // calendar_events forever and the UI showed a ghost that "wouldn't delete". it('prunes cached events whose uid is absent from the server (delete reconciliation)', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); // Server returns ONE timed event; any other cached uid for this calendar must be pruned. const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.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); // A prune DELETE must run, scoped by calendarId AND excluding the seen uid(s). expect(mockDelete).toHaveBeenCalledTimes(1); expect(mockDeleteWhere).toHaveBeenCalledTimes(1); }); it('prunes the entire calendar cache when the server returns zero events', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test Calendar', ctag: 'ctag-empty', syncToken: null, }; await syncCalendar(mockClient as never, mockDavCal as never, 1); // Empty server result → prune-all DELETE (scoped to this calendar id only). expect(mockDelete).toHaveBeenCalledTimes(1); expect(mockDeleteWhere).toHaveBeenCalledTimes(1); }); // NEW-WR-01: When the server returns zero events (whole-cache clear), the // onChanges callback must still receive a 'delete' change for each cached row // that is being pruned. Before this fix, the pendingDeleteRows pre-capture only // ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently // dropped all delete change events. it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); // First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit } // Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) → // must return an iterable array of cached rows. Use mockReturnValueOnce for the // second call so it returns a resolved Promise instead of { limit }. // // Call order when seenUids.length === 0 and onChanges is provided: // 1. db.select().from(calendars).where(...).limit(1) — calendar row lookup // 2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly) mockWhere .mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup .mockResolvedValueOnce([ // call 2: pending-delete capture { uid: 'uid-to-delete-1', title: 'Event A' }, { uid: 'uid-to-delete-2', title: 'Event B' }, ]); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]), }; const mockDavCal = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test Calendar', ctag: 'ctag-empty', syncToken: null, }; const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = []; const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => { collectedChanges.push(...changes); }; await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges); // The callback must have received one delete change per cached row. expect(collectedChanges).toHaveLength(2); expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' }); expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' }); }); }); // ─── Phase 11 Plan 03 Task 2: reminderLeadMinutes derived from VALARM on sync ── // CAL-13: sync.ts must derive reminderLeadMinutes from the native VALARM and write it // to the DB so the scheduler has ground truth for native-client alarms (D-07/NOTIF-05). describe('syncCalendar — reminderLeadMinutes from VALARM (Phase 11 Plan 03 Task 2)', () => { const MOCK_DAV_CAL = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', displayName: 'Test Calendar', ctag: 'ctag-v1', syncToken: null, }; beforeEach(() => { vi.clearAllMocks(); mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]); mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate }); mockInsert.mockReturnValue({ values: mockValues }); mockLimit.mockResolvedValue([{ id: 42 }]); mockWhere.mockReturnValue({ limit: mockLimit }); mockFrom.mockReturnValue({ where: mockWhere }); mockSelect.mockReturnValue({ from: mockFrom }); mockDeleteWhere.mockResolvedValue([]); mockDelete.mockReturnValue({ where: mockDeleteWhere }); }); // A single preset TRIGGER:-PT30M → reminderLeadMinutes=30 it('writes reminderLeadMinutes=30 when VCALENDAR has a single TRIGGER:-PT30M VALARM', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const rawVeventWithValarm = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:uid-with-valarm@test', 'SUMMARY:Meeting with reminder', 'DTSTART:20260615T140000Z', 'DTEND:20260615T150000Z', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'TRIGGER:-PT30M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: rawVeventWithValarm, etag: '"etag-valarm"', url: '/cal/valarm.ics' }, ]), }; await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); const eventValuesArg = mockValues.mock.calls[1][0]; expect(eventValuesArg.reminderLeadMinutes).toBe(30); }); // No VALARM → reminderLeadMinutes=null it('writes reminderLeadMinutes=null when VCALENDAR has no VALARM', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const rawVeventNoValarm = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:uid-no-valarm@test', 'SUMMARY:Event without reminder', 'DTSTART:20260615T140000Z', 'DTEND:20260615T150000Z', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: rawVeventNoValarm, etag: '"etag-no-valarm"', url: '/cal/no-valarm.ics' }, ]), }; await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); const eventValuesArg = mockValues.mock.calls[1][0]; expect(eventValuesArg.reminderLeadMinutes).toBeNull(); }); // Absolute DATE-TIME trigger → reminderLeadMinutes=null (custom kind, D-07/NOTIF-05) it('writes reminderLeadMinutes=null when VALARM has absolute DATE-TIME trigger (custom → null)', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const rawVeventAbsoluteValarm = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:uid-absolute-valarm@test', 'SUMMARY:Event with absolute VALARM', 'DTSTART:20260615T140000Z', 'DTEND:20260615T150000Z', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'TRIGGER;VALUE=DATE-TIME:20260615T120000Z', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([ { data: rawVeventAbsoluteValarm, etag: '"etag-abs"', url: '/cal/abs.ics', }, ]), }; await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); const eventValuesArg = mockValues.mock.calls[1][0]; // Absolute DATE-TIME trigger → classifyValarms returns 'custom' → null expect(eventValuesArg.reminderLeadMinutes).toBeNull(); }); // Two VALARMs → reminderLeadMinutes=null (custom, multiple alarms not resolvable to one lead) it('writes reminderLeadMinutes=null when VCALENDAR has two VALARMs (multiple → custom → null)', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const rawVeventTwoValarms = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:uid-two-valarms@test', 'SUMMARY:Event with two alarms', 'DTSTART:20260615T140000Z', 'DTEND:20260615T150000Z', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:First Reminder', 'TRIGGER:-PT30M', 'END:VALARM', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Second Reminder', 'TRIGGER:-PT15M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([ { data: rawVeventTwoValarms, etag: '"etag-two"', url: '/cal/two.ics', }, ]), }; await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); const eventValuesArg = mockValues.mock.calls[1][0]; // Multiple VALARMs → classifyValarms returns 'custom' → null expect(eventValuesArg.reminderLeadMinutes).toBeNull(); }); // Ensure the onDuplicateKeyUpdate ALSO sets reminderLeadMinutes (upsert column must be current) it('sets reminderLeadMinutes in onDuplicateKeyUpdate set (re-sync keeps column current)', async () => { const { syncCalendar } = await import('../../src/broker/sync.js'); const rawVeventWithValarm = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'UID:uid-upsert@test', 'SUMMARY:Recurring meeting', 'DTSTART:20260615T100000Z', 'DTEND:20260615T110000Z', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'TRIGGER:-PT15M', 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const mockClient = { fetchCalendarObjects: vi .fn() .mockResolvedValue([ { data: rawVeventWithValarm, etag: '"etag-upsert"', url: '/cal/upsert.ics' }, ]), }; await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1); // The onDuplicateKeyUpdate `set` object must also contain reminderLeadMinutes const upsertSetArg = mockOnDuplicateKeyUpdate.mock.calls[1]?.[0] as { set?: Record; }; expect(upsertSetArg?.set).toHaveProperty('reminderLeadMinutes', 15); }); });