diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index 8a05578..1788df5 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -310,6 +310,85 @@ describe('expandOccurrences', () => { }); }); + describe('reminderIsCustom — CR-01 (Phase 11 Plan 05)', () => { + it('absolute DATE-TIME VALARM trigger yields reminderIsCustom:true and reminderLeadMinutes:null', () => { + // Apple Calendar default all-day alarm style: TRIGGER;VALUE=DATE-TIME:... + // Before the fix, expand.ts maps {kind:'custom'} to null and does NOT set reminderIsCustom. + // This test MUST FAIL before the fix (no reminderIsCustom field on CalendarOccurrence). + const rawVevent = loadFixture('absolute-alarm.ics'); + const windowStart = new Date('2026-12-01T00:00:00Z'); + const windowEnd = new Date('2027-01-01T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences.length).toBe(1); + const occ = occurrences[0]; + // reminderIsCustom MUST be true (absolute DATE-TIME trigger) + expect((occ as Record).reminderIsCustom).toBe(true); + // reminderLeadMinutes MUST be null (cannot reduce custom to a lead) + expect(occ.reminderLeadMinutes).toBeNull(); + }); + + it('multiple VALARMs yield reminderIsCustom:true and reminderLeadMinutes:null', () => { + // Outlook/Apple sometimes produce two VALARMs — classifyValarms returns {kind:'custom'} + const rawVevent = loadFixture('multi-alarm.ics'); + const windowStart = new Date('2026-12-01T00:00:00Z'); + const windowEnd = new Date('2027-01-01T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + expect(occurrences.length).toBe(1); + const occ = occurrences[0]; + expect((occ as Record).reminderIsCustom).toBe(true); + expect(occ.reminderLeadMinutes).toBeNull(); + }); + + it('single relative preset VALARM yields reminderIsCustom:false', () => { + // A normal -PT15M DURATION trigger should NOT be flagged as custom + const rawVevent = loadFixture('weekly-dst.ics'); + const windowStart = new Date('2026-03-01T00:00:00Z'); + const windowEnd = new Date('2026-03-15T00:00:00Z'); + + const occurrences = expandOccurrences( + rawVevent, + windowStart, + windowEnd, + 1, + 'My Calendar', + 1, + null, + '#4A90D9', + false, + ); + + // weekly-dst.ics has no VALARM — reminderIsCustom should be false + expect(occurrences.length).toBeGreaterThan(0); + for (const occ of occurrences) { + expect((occ as Record).reminderIsCustom).toBe(false); + } + }); + }); + describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => { it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => { // This is the integration test that was missing. It takes the actual serializeTime output diff --git a/apps/api/tests/fixtures/absolute-alarm.ics b/apps/api/tests/fixtures/absolute-alarm.ics new file mode 100644 index 0000000..874fe80 --- /dev/null +++ b/apps/api/tests/fixtures/absolute-alarm.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Calendar//NONSGML Version 1//EN +BEGIN:VEVENT +UID:absolute-alarm-test@familysync.test +DTSTART;VALUE=DATE:20261215 +SUMMARY:Holiday Party +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER;VALUE=DATE-TIME:20261214T140000Z +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/apps/api/tests/fixtures/multi-alarm.ics b/apps/api/tests/fixtures/multi-alarm.ics new file mode 100644 index 0000000..6730c85 --- /dev/null +++ b/apps/api/tests/fixtures/multi-alarm.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Outlook//NONSGML Version 1//EN +BEGIN:VEVENT +UID:multi-alarm-test@familysync.test +DTSTART:20261215T100000Z +DTEND:20261215T110000Z +SUMMARY:Team Meeting +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder 1 +TRIGGER:-PT15M +END:VALARM +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder 2 +TRIGGER:-PT5M +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index d5f326d..1a726a8 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -1231,3 +1231,94 @@ describe('EventForm — Phase 11 reminder picker (Plan 04)', () => { }); }); }); + +// ── Phase 11 Plan 05: CR-01 custom alarm round-trip (TDD RED) ───────────────── + +/** + * Fixture: edit occurrence with reminderIsCustom:true (absolute DATE-TIME or multi-VALARM). + * This field is added by Plan 05 — before the fix, CalendarOccurrence does not carry it, + * so the form cannot distinguish custom from no-reminder. + */ +const CUSTOM_ALARM_OCCURRENCE: CalendarOccurrence & { reminderIsCustom?: boolean } = { + id: 'custom-alarm-uid::2026-12-15', + uid: 'custom-alarm-uid', + calendarId: 1, + calendarName: 'My Calendar', + ownerUserId: 1, + ownerName: 'Alice', + color: '#4A90D9', + isShared: false, + title: 'Holiday Party', + start: '2026-12-15', + end: '2026-12-16', + allDay: true, + location: null, + description: null, + hasRrule: false, + reminderLeadMinutes: null, // custom alarms cannot be reduced to a lead + reminderIsCustom: true, // CR-01 new field: signals absolute/multi alarm +}; + +describe('EventForm — Phase 11 Plan 05 CR-01: custom alarm round-trip', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEventFormOpen = true; + mockEventFormMode = 'create'; + mockEventFormUid = null; + }); + + it('CR-01: edit with reminderIsCustom=true initializes picker to __custom__ (not __none__)', () => { + // Before the fix: occurrence.reminderIsCustom does not exist; deriveReminderValue(null, ...) + // returns '__none__'. This test MUST FAIL before the fix. + renderForm({ + mode: 'edit', + uid: 'custom-alarm-uid', + eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence, + }); + + const reminderSelect = document.querySelector('#event-reminder') as HTMLSelectElement; + expect(reminderSelect).not.toBeNull(); + // Must be __custom__, NOT __none__ (the pre-fix incorrect value) + expect(reminderSelect.value).toBe('__custom__'); + }); + + it('CR-01: Custom (kept) disabled option is visible when reminderIsCustom=true', () => { + renderForm({ + mode: 'edit', + uid: 'custom-alarm-uid', + eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence, + }); + + // The read-only "Custom (kept)" option must be visible + const customOption = screen.queryByText('Custom (kept)'); + expect(customOption).not.toBeNull(); + }); + + it('CR-01: submitting in __custom__ state omits reminderLeadMinutes from payload (D-08 preserve)', async () => { + // The critical data-loss test: edit a custom-alarm event, change the title, save. + // The payload must NOT include reminderLeadMinutes (field absent = preserve VALARM). + renderForm({ + mode: 'edit', + uid: 'custom-alarm-uid', + eventOccurrence: CUSTOM_ALARM_OCCURRENCE as CalendarOccurrence, + }); + + // Change the title to simulate a real edit + fireEvent.change(screen.getByPlaceholderText('Event title'), { + target: { value: 'Holiday Party (Updated)' }, + }); + + fireEvent.click(screen.getByText('Save Changes')); + + await waitFor(() => { + expect(mockUpdateEvent).toHaveBeenCalled(); + const callPayload = mockUpdateEvent.mock.calls[0][1] as Record; + // MUST be absent: the presence of reminderLeadMinutes:null would cause the outbox + // worker to clear the VALARM — the CR-01 data-loss bug. + expect(Object.prototype.hasOwnProperty.call(callPayload, 'reminderLeadMinutes')).toBe( + false, + 'reminderLeadMinutes must be absent from payload when alarm is custom (D-08 preserve path)', + ); + }); + }); +});