fix(02): populate hasRrule on every sync upsert so recurring masters are flagged
- Use ICAL.Event.isRecurring() (parity with expand.ts) to detect RRULE/RDATE - Add hasRrule to .values() INSERT and .onDuplicateKeyUpdate() SET so the flag is set on first sync and self-heals on every subsequent re-sync - Without this fix every event had has_rrule=0 (column default), causing the events route recurring-master pre-filter to return zero recurring occurrences - Add sync.test.ts cases: hasRrule=true for timed+all-day recurring VEVENTs, hasRrule=false for non-recurring, and hasRrule in onDuplicateKeyUpdate.set
This commit is contained in:
@@ -89,6 +89,10 @@ export async function syncCalendar(
|
|||||||
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
|
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
|
||||||
const allDay: boolean = dtstart?.isDate ?? false
|
const allDay: boolean = dtstart?.isDate ?? false
|
||||||
|
|
||||||
|
// Determine if this event is a recurring master (has RRULE or RDATE).
|
||||||
|
// Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties.
|
||||||
|
const isRecurring: boolean = new ICAL.Event(vevent).isRecurring()
|
||||||
|
|
||||||
// dtstartDate: Drizzle's `date` column accepts a Date object or null.
|
// dtstartDate: Drizzle's `date` column accepts a Date object or null.
|
||||||
// We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so
|
// We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so
|
||||||
// Drizzle serialises it correctly as a DATE without a time component.
|
// Drizzle serialises it correctly as a DATE without a time component.
|
||||||
@@ -106,6 +110,7 @@ export async function syncCalendar(
|
|||||||
dtstartUtc: dtstartUtcValue,
|
dtstartUtc: dtstartUtcValue,
|
||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
|
hasRrule: isRecurring,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
set: {
|
set: {
|
||||||
@@ -114,6 +119,7 @@ export async function syncCalendar(
|
|||||||
dtstartUtc: dtstartUtcValue,
|
dtstartUtc: dtstartUtcValue,
|
||||||
dtstartDate: dtstartDateValue,
|
dtstartDate: dtstartDateValue,
|
||||||
allDay,
|
allDay,
|
||||||
|
hasRrule: isRecurring,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
SAMPLE_VEVENT_TIMED,
|
SAMPLE_VEVENT_TIMED,
|
||||||
SAMPLE_VEVENT_ALLDAY,
|
SAMPLE_VEVENT_ALLDAY,
|
||||||
|
SAMPLE_VEVENT_RECURRING_TIMED,
|
||||||
|
SAMPLE_VEVENT_RECURRING_ALLDAY,
|
||||||
} from '../helpers/db.js'
|
} from '../helpers/db.js'
|
||||||
|
|
||||||
// Track calls for assertions
|
// Track calls for assertions
|
||||||
@@ -157,6 +159,94 @@ describe('syncCalendar', () => {
|
|||||||
expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED)
|
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('updates the calendar ctag/syncToken after a successful sync', async () => {
|
it('updates the calendar ctag/syncToken after a successful sync', async () => {
|
||||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||||
|
|
||||||
|
|||||||
@@ -57,3 +57,34 @@ DTEND;VALUE=DATE:20260616
|
|||||||
SUMMARY:Test All-Day Event
|
SUMMARY:Test All-Day Event
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR`
|
END:VCALENDAR`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample VEVENT string — a timed recurring event (RRULE:FREQ=WEEKLY).
|
||||||
|
* Used to test that sync sets hasRrule=true for recurring events.
|
||||||
|
*/
|
||||||
|
export const SAMPLE_VEVENT_RECURRING_TIMED = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//FamilySync//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:test-recurring-timed-001@familysync
|
||||||
|
DTSTART:20240101T100000Z
|
||||||
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||||
|
SUMMARY:Weekly Monday Meeting
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample VEVENT string — an all-day recurring event (RRULE:FREQ=YEARLY).
|
||||||
|
* Used to test that sync sets hasRrule=true for all-day recurring events,
|
||||||
|
* and that the events route returns occurrences even when dtstartUtc is NULL.
|
||||||
|
*/
|
||||||
|
export const SAMPLE_VEVENT_RECURRING_ALLDAY = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//FamilySync//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:test-recurring-allday-001@familysync
|
||||||
|
DTSTART;VALUE=DATE:20240615
|
||||||
|
RRULE:FREQ=YEARLY
|
||||||
|
SUMMARY:Annual Birthday
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`
|
||||||
|
|||||||
Reference in New Issue
Block a user