/** * 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 }) // Mock the db singleton at module level (Vitest hoisting) vi.mock('../../src/db/client.js', () => ({ db: { insert: mockInsert, select: mockSelect, }, })) 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 }) }) 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') }) })