- apps/pwa/src/components/EventForm.tsx (Task 2)
- apps/api/src/broker/{expand,reminderScheduler,sync,vevent}.ts (Plans 11-01/11-03)
- apps/api/tests/broker/{reminderScheduler,sync}.test.ts (Plans 11-01/11-03)
642 lines
23 KiB
TypeScript
642 lines
23 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 });
|
|
// Prune chain: db.delete(calendarEvents).where(...)
|
|
const mockDeleteWhere = vi.fn().mockResolvedValue([]);
|
|
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere });
|
|
|
|
// Mock the db singleton at module level (Vitest hoisting)
|
|
vi.mock('../../src/db/client.js', () => ({
|
|
db: {
|
|
insert: mockInsert,
|
|
select: mockSelect,
|
|
delete: mockDelete,
|
|
},
|
|
}));
|
|
|
|
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 });
|
|
mockDeleteWhere.mockResolvedValue([]);
|
|
mockDelete.mockReturnValue({ where: mockDeleteWhere });
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// Regression: deletes must be reconciled out of the cache. Before this fix,
|
|
// syncCalendar only upserted present events, so a deleted event lingered in
|
|
// calendar_events forever and the UI showed a ghost that "wouldn't delete".
|
|
it('prunes cached events whose uid is absent from the server (delete reconciliation)', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
// Server returns ONE timed event; any other cached uid for this calendar must be pruned.
|
|
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-v2',
|
|
syncToken: null,
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
|
|
|
// A prune DELETE must run, scoped by calendarId AND excluding the seen uid(s).
|
|
expect(mockDelete).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('prunes the entire calendar cache when the server returns zero events', 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: 'ctag-empty',
|
|
syncToken: null,
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
|
|
|
// Empty server result → prune-all DELETE (scoped to this calendar id only).
|
|
expect(mockDelete).toHaveBeenCalledTimes(1);
|
|
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// NEW-WR-01: When the server returns zero events (whole-cache clear), the
|
|
// onChanges callback must still receive a 'delete' change for each cached row
|
|
// that is being pruned. Before this fix, the pendingDeleteRows pre-capture only
|
|
// ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently
|
|
// dropped all delete change events.
|
|
it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
// First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit }
|
|
// Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) →
|
|
// must return an iterable array of cached rows. Use mockReturnValueOnce for the
|
|
// second call so it returns a resolved Promise<array> instead of { limit }.
|
|
//
|
|
// Call order when seenUids.length === 0 and onChanges is provided:
|
|
// 1. db.select().from(calendars).where(...).limit(1) — calendar row lookup
|
|
// 2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly)
|
|
mockWhere
|
|
.mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
|
|
.mockResolvedValueOnce([
|
|
// call 2: pending-delete capture
|
|
{ uid: 'uid-to-delete-1', title: 'Event A' },
|
|
{ uid: 'uid-to-delete-2', title: 'Event B' },
|
|
]);
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
|
|
};
|
|
const mockDavCal = {
|
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
displayName: 'Test Calendar',
|
|
ctag: 'ctag-empty',
|
|
syncToken: null,
|
|
};
|
|
|
|
const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = [];
|
|
const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => {
|
|
collectedChanges.push(...changes);
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges);
|
|
|
|
// The callback must have received one delete change per cached row.
|
|
expect(collectedChanges).toHaveLength(2);
|
|
expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' });
|
|
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
|
|
});
|
|
});
|
|
|
|
// ─── Phase 11 Plan 03 Task 2: reminderLeadMinutes derived from VALARM on sync ──
|
|
// CAL-13: sync.ts must derive reminderLeadMinutes from the native VALARM and write it
|
|
// to the DB so the scheduler has ground truth for native-client alarms (D-07/NOTIF-05).
|
|
|
|
describe('syncCalendar — reminderLeadMinutes from VALARM (Phase 11 Plan 03 Task 2)', () => {
|
|
const MOCK_DAV_CAL = {
|
|
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
|
displayName: 'Test Calendar',
|
|
ctag: 'ctag-v1',
|
|
syncToken: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.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 });
|
|
mockDeleteWhere.mockResolvedValue([]);
|
|
mockDelete.mockReturnValue({ where: mockDeleteWhere });
|
|
});
|
|
|
|
// A single preset TRIGGER:-PT30M → reminderLeadMinutes=30
|
|
it('writes reminderLeadMinutes=30 when VCALENDAR has a single TRIGGER:-PT30M VALARM', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
const rawVeventWithValarm = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'BEGIN:VEVENT',
|
|
'UID:uid-with-valarm@test',
|
|
'SUMMARY:Meeting with reminder',
|
|
'DTSTART:20260615T140000Z',
|
|
'DTEND:20260615T150000Z',
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
'DESCRIPTION:Reminder',
|
|
'TRIGGER:-PT30M',
|
|
'END:VALARM',
|
|
'END:VEVENT',
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi
|
|
.fn()
|
|
.mockResolvedValue([
|
|
{ data: rawVeventWithValarm, etag: '"etag-valarm"', url: '/cal/valarm.ics' },
|
|
]),
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
|
|
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
|
expect(eventValuesArg.reminderLeadMinutes).toBe(30);
|
|
});
|
|
|
|
// No VALARM → reminderLeadMinutes=null
|
|
it('writes reminderLeadMinutes=null when VCALENDAR has no VALARM', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
const rawVeventNoValarm = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'BEGIN:VEVENT',
|
|
'UID:uid-no-valarm@test',
|
|
'SUMMARY:Event without reminder',
|
|
'DTSTART:20260615T140000Z',
|
|
'DTEND:20260615T150000Z',
|
|
'END:VEVENT',
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi
|
|
.fn()
|
|
.mockResolvedValue([
|
|
{ data: rawVeventNoValarm, etag: '"etag-no-valarm"', url: '/cal/no-valarm.ics' },
|
|
]),
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
|
|
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
|
});
|
|
|
|
// Absolute DATE-TIME trigger → reminderLeadMinutes=null (custom kind, D-07/NOTIF-05)
|
|
it('writes reminderLeadMinutes=null when VALARM has absolute DATE-TIME trigger (custom → null)', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
const rawVeventAbsoluteValarm = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'BEGIN:VEVENT',
|
|
'UID:uid-absolute-valarm@test',
|
|
'SUMMARY:Event with absolute VALARM',
|
|
'DTSTART:20260615T140000Z',
|
|
'DTEND:20260615T150000Z',
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
'DESCRIPTION:Reminder',
|
|
'TRIGGER;VALUE=DATE-TIME:20260615T120000Z',
|
|
'END:VALARM',
|
|
'END:VEVENT',
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
|
{
|
|
data: rawVeventAbsoluteValarm,
|
|
etag: '"etag-abs"',
|
|
url: '/cal/abs.ics',
|
|
},
|
|
]),
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
|
|
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
|
// Absolute DATE-TIME trigger → classifyValarms returns 'custom' → null
|
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
|
});
|
|
|
|
// Two VALARMs → reminderLeadMinutes=null (custom, multiple alarms not resolvable to one lead)
|
|
it('writes reminderLeadMinutes=null when VCALENDAR has two VALARMs (multiple → custom → null)', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
const rawVeventTwoValarms = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'BEGIN:VEVENT',
|
|
'UID:uid-two-valarms@test',
|
|
'SUMMARY:Event with two alarms',
|
|
'DTSTART:20260615T140000Z',
|
|
'DTEND:20260615T150000Z',
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
'DESCRIPTION:First Reminder',
|
|
'TRIGGER:-PT30M',
|
|
'END:VALARM',
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
'DESCRIPTION:Second Reminder',
|
|
'TRIGGER:-PT15M',
|
|
'END:VALARM',
|
|
'END:VEVENT',
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
|
{
|
|
data: rawVeventTwoValarms,
|
|
etag: '"etag-two"',
|
|
url: '/cal/two.ics',
|
|
},
|
|
]),
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
|
|
|
const eventValuesArg = mockValues.mock.calls[1][0];
|
|
// Multiple VALARMs → classifyValarms returns 'custom' → null
|
|
expect(eventValuesArg.reminderLeadMinutes).toBeNull();
|
|
});
|
|
|
|
// Ensure the onDuplicateKeyUpdate ALSO sets reminderLeadMinutes (upsert column must be current)
|
|
it('sets reminderLeadMinutes in onDuplicateKeyUpdate set (re-sync keeps column current)', async () => {
|
|
const { syncCalendar } = await import('../../src/broker/sync.js');
|
|
|
|
const rawVeventWithValarm = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
'BEGIN:VEVENT',
|
|
'UID:uid-upsert@test',
|
|
'SUMMARY:Recurring meeting',
|
|
'DTSTART:20260615T100000Z',
|
|
'DTEND:20260615T110000Z',
|
|
'BEGIN:VALARM',
|
|
'ACTION:DISPLAY',
|
|
'DESCRIPTION:Reminder',
|
|
'TRIGGER:-PT15M',
|
|
'END:VALARM',
|
|
'END:VEVENT',
|
|
'END:VCALENDAR',
|
|
].join('\r\n');
|
|
|
|
const mockClient = {
|
|
fetchCalendarObjects: vi
|
|
.fn()
|
|
.mockResolvedValue([
|
|
{ data: rawVeventWithValarm, etag: '"etag-upsert"', url: '/cal/upsert.ics' },
|
|
]),
|
|
};
|
|
|
|
await syncCalendar(mockClient as never, MOCK_DAV_CAL as never, 1);
|
|
|
|
// The onDuplicateKeyUpdate `set` object must also contain reminderLeadMinutes
|
|
const upsertSetArg = mockOnDuplicateKeyUpdate.mock.calls[1]?.[0] as {
|
|
set?: Record<string, unknown>;
|
|
};
|
|
expect(upsertSetArg?.set).toHaveProperty('reminderLeadMinutes', 15);
|
|
});
|
|
});
|