BUG A — timed events written 4h off: EventForm sent a naive local wall-clock string with no offset; the UTC API container parsed it via new Date() as UTC, so 09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC instant in the browser (where the operator's zone is known); all-day stays a DATE string. No backend change. BUG B — created events attached to the wrong user's calendar + duplicate calendar rows per poll: calendars had no unique key on url, and poller/sync matched calendars by url alone — so under the shared single Fastmail account (D-16) one member's collection resolved to the other member's row. Fix: composite unique(user_id, url); scope poller lookup + sync select to (userId, url); hand migration 0001 (dedup + add key), applied to the live DB. Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140, tsc clean both packages.
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
/**
|
|
* 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<string, unknown>)
|
|
? (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')
|
|
})
|
|
})
|