/** * 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, } from '../helpers/db.js' // Mock the db singleton at module level (Vitest hoisting) vi.mock('../../src/db/client.js', () => { const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ id: 1 }]) const values = vi.fn().mockReturnValue({ onDuplicateKeyUpdate }) const insert = vi.fn().mockReturnValue({ values }) return { db: { insert }, } }) describe('syncCalendar', () => { beforeEach(() => { vi.clearAllMocks() }) it('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL', async () => { const { db } = await import('../../src/db/client.js') 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) const insertMock = vi.mocked(db.insert) // Should have been called at least twice: once for calendars, once for calendarEvents expect(insertMock).toHaveBeenCalledTimes(2) // The second insert call is for calendarEvents const eventValuesArg = vi.mocked(insertMock).mock.results[1].value.values.mock.calls[0][0] expect(eventValuesArg.allDay).toBe(true) expect(eventValuesArg.dtstartDate).toBeTruthy() // YYYY-MM-DD string expect(eventValuesArg.dtstartUtc).toBeNull() }) it('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL', async () => { const { db } = await import('../../src/db/client.js') 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) const insertMock = vi.mocked(db.insert) expect(insertMock).toHaveBeenCalledTimes(2) const eventValuesArg = vi.mocked(insertMock).mock.results[1].value.values.mock.calls[0][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 { db } = await import('../../src/db/client.js') 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 insertMock = vi.mocked(db.insert) const eventArg = vi.mocked(insertMock).mock.results[1].value.values.mock.calls[0][0] expect(eventArg.allDay).toBe(true) }) it('upserts on duplicate UID within the same calendar (onDuplicateKeyUpdate called)', async () => { const { db } = await import('../../src/db/client.js') 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) // Both calendar insert and event insert must use onDuplicateKeyUpdate const insertMock = vi.mocked(db.insert) const calOnDup = vi.mocked(insertMock).mock.results[0].value.values.mock.results[0].value.onDuplicateKeyUpdate const evtOnDup = vi.mocked(insertMock).mock.results[1].value.values.mock.results[0].value.onDuplicateKeyUpdate expect(calOnDup).toHaveBeenCalledTimes(1) expect(evtOnDup).toHaveBeenCalledTimes(1) }) it('stores the raw VEVENT blob in rawVevent column', async () => { const { db } = await import('../../src/db/client.js') 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 insertMock = vi.mocked(db.insert) const eventArg = vi.mocked(insertMock).mock.results[1].value.values.mock.calls[0][0] expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED) }) it('updates the calendar ctag/syncToken after a successful sync', async () => { const { db } = await import('../../src/db/client.js') 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) const insertMock = vi.mocked(db.insert) const calValuesArg = vi.mocked(insertMock).mock.results[0].value.values.mock.calls[0][0] expect(calValuesArg.ctag).toBe('new-ctag-123') expect(calValuesArg.syncToken).toBe('sync-token-abc') }) })