fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
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.
This commit is contained in:
@@ -194,6 +194,44 @@ describe('broker poller — runPoll', () => {
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => {
|
||||
// Capture the predicate passed to db.select().from(calendars).where(...).
|
||||
// The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)).
|
||||
// We serialize the predicate and assert it references the member's user_id column.
|
||||
const capturedWhere: unknown[] = []
|
||||
mockSelectWhere.mockImplementation((pred: unknown) => {
|
||||
capturedWhere.push(pred)
|
||||
return { limit: mockSelectLimit }
|
||||
})
|
||||
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
|
||||
mockCredentialsSelectResult.push({
|
||||
id: 7,
|
||||
userId: 42,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'enc',
|
||||
})
|
||||
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Calendar', ctag: 'c', syncToken: null },
|
||||
])
|
||||
|
||||
await runPoll()
|
||||
|
||||
expect(capturedWhere.length).toBeGreaterThan(0)
|
||||
// A composite and(...) predicate exposes multiple queryChunks; a single eq does not
|
||||
// contain a nested SQL referencing the user_id column. Serialize and inspect.
|
||||
const pred = capturedWhere[0] as { queryChunks?: unknown[] }
|
||||
const serialized = JSON.stringify(pred, (_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('processes all member credentials in a poll cycle', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
|
||||
|
||||
@@ -247,6 +247,52 @@ describe('syncCalendar', () => {
|
||||
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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user