/** * Tests for expandOccurrences() — GREEN state. * * Contracts verified here: * 1. DST wall-clock correctness: occurrences in America/New_York must show 10:00 local time * on BOTH sides of the March 2026 EST→EDT boundary (not shifted ±1h by UTC fallback). * 2. All-day events return allDay:true and start as 'YYYY-MM-DD' with no time component. * 3. EXDATE exclusions reduce the returned array by exactly one occurrence. * 4. Timed event start/end strings are IANA-annotated ('...±HH:MM[IANA/Zone]') so * Temporal.ZonedDateTime.from() can parse them without throwing (cross-contract test). * 5. hasRrule is true for recurring events, false for non-recurring (D-08). * 6. Bounded RRULE (COUNT=3) expands to exactly 3 occurrences; each occurrence's * duration derives from DTSTART→DTEND (not the recurrence span) (D-06 invariant). */ import 'temporal-polyfill/global'; import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { expandOccurrences } from '../../src/broker/expand.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES = join(__dirname, '../fixtures'); function loadFixture(name: string): string { // eslint-disable-next-line security/detect-non-literal-fs-filename -- name is a test-controlled fixture filename, not user input return readFileSync(join(FIXTURES, name), 'utf8'); } describe('expandOccurrences', () => { describe('DST correctness — weekly-dst.ics', () => { it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => { // The fixture has DTSTART;TZID=America/New_York:20260301T100000 RRULE:FREQ=WEEKLY. // March 8 2026 is the DST transition (clocks spring forward at 02:00). // Occurrences on 2026-03-01 (EST, UTC-5) and 2026-03-08 (DST transition day) and // 2026-03-15 (EDT, UTC-4) must ALL show hour === 10 in America/New_York. // A broken implementation that falls back to UTC would show hour === 10 UTC before // transition and hour === 11 local after transition — off by one DST hour. const rawVevent = loadFixture('weekly-dst.ics'); const windowStart = new Date('2026-03-01T00:00:00Z'); const windowEnd = new Date('2026-04-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, // calendarId 'My Calendar', // calendarName 1, // ownerUserId 'Alice', // ownerName '#4A90D9', // color false, // isShared ); // Should return several weekly occurrences in March expect(occurrences.length).toBeGreaterThan(0); // The key contract: every occurrence must have local hour === 10 in America/New_York. // We verify this by checking the ISO string — before DST: '...T10:00:00-05:00[America/New_York]' // after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket. for (const occ of occurrences) { expect(occ.allDay).toBe(false); // ownerName must be threaded through to every occurrence expect(occ.ownerName).toBe('Alice'); // start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]' expect(occ.start).toMatch(/T10:00:00/); // Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from() expect(occ.start).toContain('[America/New_York]'); } // Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT) const preTransition = occurrences.find((o) => o.start.includes('2026-03-01')); const postTransition = occurrences.find((o) => o.start.includes('2026-03-15')); expect(preTransition).toBeDefined(); expect(postTransition).toBeDefined(); // Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]' expect(preTransition!.start).toContain('T10:00:00'); expect(preTransition!.start).toContain('-05:00[America/New_York]'); // Post-transition occurrence: EDT — '2026-03-15T10:00:00-04:00[America/New_York]' expect(postTransition!.start).toContain('T10:00:00'); expect(postTransition!.start).toContain('-04:00[America/New_York]'); }); }); describe('All-day events — allday-birthday.ics', () => { it('returns allDay:true with start as YYYY-MM-DD and no time component', () => { // The fixture has DTSTART;VALUE=DATE:20260615 with RRULE:FREQ=YEARLY. // The all-day occurrence should have allDay:true and start === '2026-06-15' (DATE format). // A broken implementation returning '2026-06-15T00:00:00Z' would fail on date-shift. const rawVevent = loadFixture('allday-birthday.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-07-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, // ownerName '#4A90D9', false, ); expect(occurrences.length).toBe(1); const occ = occurrences[0]; expect(occ.allDay).toBe(true); // start must be plain date string 'YYYY-MM-DD' — no 'T' time component expect(occ.start).toBe('2026-06-15'); expect(occ.start).not.toContain('T'); }); }); describe('EXDATE exclusions — exdate-series.ics', () => { it('omits the EXDATE-excluded occurrence (array length is one fewer than un-excluded)', () => { // The fixture has RRULE:FREQ=WEEKLY;COUNT=5 with EXDATE for the June 15 occurrence. // Without EXDATE: 5 occurrences (Jun 1, Jun 8, Jun 15, Jun 22, Jun 29). // With EXDATE on Jun 15: 4 occurrences returned. // ICAL.RecurExpansion handles EXDATE internally — no manual filtering needed. const rawVevent = loadFixture('exdate-series.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-07-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, // ownerName '#4A90D9', false, ); // 5 total occurrences minus 1 EXDATE = 4 expect(occurrences.length).toBe(4); // The June 15 occurrence must be absent const june15 = occurrences.find((o) => o.start.includes('2026-06-15')); expect(june15).toBeUndefined(); }); }); describe('Non-recurring DURATION-only event — single-duration.ics', () => { it('BUG-1 regression: non-recurring event with DURATION but no DTEND has end strictly after start', () => { // Real Fastmail events use DURATION (not DTEND). Before the fix, the NON-RECURRING branch // used getFirstPropertyValue('dtend') ?? dtstart, which returned dtstart when DTEND was absent, // producing zero-duration occurrences (invisible in week/day views). const rawVevent = loadFixture('single-duration.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-07-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, // ownerName '#4A90D9', false, ); expect(occurrences.length).toBe(1); const occ = occurrences[0]; expect(occ.allDay).toBe(false); // Parse both via Temporal.ZonedDateTime and assert end > start const startZdt = Temporal.ZonedDateTime.from(occ.start); const endZdt = Temporal.ZonedDateTime.from(occ.end); expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0); // Verify the actual duration is correct: DURATION:PT1H → end is 1 hour after start expect(endZdt.hour - startZdt.hour).toBe(1); expect(endZdt.minute).toBe(startZdt.minute); }); }); describe('hasRrule field — D-08 (recurring series detection)', () => { it('recurring event occurrence has hasRrule === true', () => { // weekly-dst.ics has RRULE:FREQ=WEEKLY — all occurrences must have hasRrule === true const rawVevent = loadFixture('weekly-dst.ics'); const windowStart = new Date('2026-03-01T00:00:00Z'); const windowEnd = new Date('2026-04-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBeGreaterThan(0); for (const occ of occurrences) { expect(occ.hasRrule).toBe(true); } }); it('non-recurring event occurrence has hasRrule === false', () => { // single-duration.ics has no RRULE — the single occurrence must have hasRrule === false const rawVevent = loadFixture('single-duration.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-07-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBe(1); expect(occurrences[0].hasRrule).toBe(false); }); }); describe('Bounded RRULE (COUNT=3) — D-06 invariant', () => { it('expands to exactly 3 occurrences within a wide window (COUNT terminates expansion)', () => { // weekly-count3.ics has RRULE:FREQ=WEEKLY;COUNT=3 starting 2026-06-01. // A 6-month window must return exactly 3 occurrences — not more. const rawVevent = loadFixture('weekly-count3.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-12-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBe(3); }); it('each bounded occurrence duration derives from DTSTART→DTEND (1 hour), not recurrence span', () => { // Each occurrence must have end exactly 1 hour after start. // The recurrence span is many weeks; duration must be per-event, not recurrence-level. const rawVevent = loadFixture('weekly-count3.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-12-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBe(3); for (const occ of occurrences) { const startZdt = Temporal.ZonedDateTime.from(occ.start); const endZdt = Temporal.ZonedDateTime.from(occ.end); // Each occurrence must be exactly 1 hour (3 600 000 ms). // Use epochMilliseconds which is a regular number in the polyfill. const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds; expect(durationMs).toBe(3_600_000); } }); it('bounded occurrences have hasRrule === true', () => { const rawVevent = loadFixture('weekly-count3.ics'); const windowStart = new Date('2026-06-01T00:00:00Z'); const windowEnd = new Date('2026-12-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBe(3); for (const occ of occurrences) { expect(occ.hasRrule).toBe(true); } }); }); 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 // (from expandOccurrences) and feeds each start/end through Temporal.ZonedDateTime.from() // to prove the expand→hydrate contract holds end-to-end. // // Previously, serializeTime emitted offset-only strings like '2026-03-01T10:00:00-05:00' // which caused Temporal.ZonedDateTime.from() to throw RangeError: Cannot parse. // Now it emits IANA-annotated strings like '2026-03-01T10:00:00-05:00[America/New_York]'. const rawVevent = loadFixture('weekly-dst.ics'); const windowStart = new Date('2026-03-01T00:00:00Z'); const windowEnd = new Date('2026-04-01T00:00:00Z'); const occurrences = expandOccurrences( rawVevent, windowStart, windowEnd, 1, 'My Calendar', 1, null, // ownerName '#4A90D9', false, ); expect(occurrences.length).toBeGreaterThan(0); for (const occ of occurrences) { // These must not throw — this is the cross-service contract expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow(); expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow(); // Parsed ZonedDateTime must round-trip the wall-clock hour const startZdt = Temporal.ZonedDateTime.from(occ.start); expect(startZdt.hour).toBe(10); expect(startZdt.timeZoneId).toBe('America/New_York'); } }); }); }); // ─── Phase 11 Plan 03 Task 3: reminderLeadMinutes on CalendarOccurrence ───────── // D-06/D-10: reminderLeadMinutes is a series-level property — all occurrences of a // recurring master inherit the master's lead. NULL-vs-0-vs-positive must survive // through expansion. describe('expandOccurrences — reminderLeadMinutes propagation (Phase 11 Plan 03 Task 3)', () => { // Helper: minimal VCALENDAR/VEVENT string for tests function makeVevent(overrides: { uid?: string; allDay?: boolean; reminderMinutes?: number | 'absolute' | 'none'; rrule?: string; }): string { const uid = overrides.uid ?? 'test-uid@test'; const lines: string[] = ['BEGIN:VCALENDAR', 'VERSION:2.0']; if (!overrides.allDay) { lines.push('BEGIN:VEVENT'); lines.push(`UID:${uid}`); lines.push('SUMMARY:Test event'); lines.push('DTSTART:20260615T140000Z'); lines.push('DTEND:20260615T150000Z'); } else { lines.push('BEGIN:VEVENT'); lines.push(`UID:${uid}`); lines.push('SUMMARY:All-day test'); lines.push('DTSTART;VALUE=DATE:20260615'); lines.push('DTEND;VALUE=DATE:20260616'); } if (overrides.rrule) { lines.push(`RRULE:${overrides.rrule}`); } if (overrides.reminderMinutes === 'absolute') { lines.push('BEGIN:VALARM'); lines.push('ACTION:DISPLAY'); lines.push('DESCRIPTION:Reminder'); lines.push('TRIGGER;VALUE=DATE-TIME:20260615T120000Z'); lines.push('END:VALARM'); } else if (overrides.reminderMinutes !== 'none' && overrides.reminderMinutes !== undefined) { lines.push('BEGIN:VALARM'); lines.push('ACTION:DISPLAY'); lines.push('DESCRIPTION:Reminder'); lines.push(`TRIGGER:-PT${overrides.reminderMinutes}M`); lines.push('END:VALARM'); } lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); return lines.join('\r\n'); } const WINDOW_START = new Date('2026-06-01T00:00:00Z'); const WINDOW_END = new Date('2026-07-01T00:00:00Z'); // Non-recurring event with reminderLeadMinutes=30 → occurrence carries 30 it('non-recurring event: occurrence carries reminderLeadMinutes from master (30 minutes)', () => { const raw = makeVevent({ reminderMinutes: 30 }); const occurrences = expandOccurrences( raw, WINDOW_START, WINDOW_END, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences).toHaveLength(1); expect(occurrences[0].reminderLeadMinutes).toBe(30); }); // NULL-vs-0: master with 0-minute all-day lead → occurrence carries 0, not null it('non-recurring all-day event: occurrence carries reminderLeadMinutes=0 (same-day, D-06 NULL-vs-0)', () => { // Use a 0-minute trigger (same-day all-day) const raw = makeVevent({ allDay: true, reminderMinutes: 0 }); const occurrences = expandOccurrences( raw, new Date('2026-06-01T00:00:00Z'), new Date('2026-07-01T00:00:00Z'), 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences).toHaveLength(1); // 0-minute all-day trigger → preset 0 → reminderLeadMinutes=0 (not null) expect(occurrences[0].reminderLeadMinutes).toBe(0); }); // NULL: no VALARM in master → occurrence carries null it('non-recurring event with no VALARM: occurrence carries reminderLeadMinutes=null', () => { const raw = makeVevent({ reminderMinutes: 'none' }); const occurrences = expandOccurrences( raw, WINDOW_START, WINDOW_END, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences).toHaveLength(1); expect(occurrences[0].reminderLeadMinutes).toBeNull(); }); // D-10 series-level: recurring event with reminderLeadMinutes=60 → all occurrences carry 60 it('D-10 series-level: all recurring occurrences inherit the master reminderLeadMinutes=60', () => { const raw = makeVevent({ reminderMinutes: 60, rrule: 'FREQ=WEEKLY;COUNT=3' }); const occurrences = expandOccurrences( raw, WINDOW_START, WINDOW_END, 1, 'My Calendar', 1, null, '#4A90D9', false, ); expect(occurrences.length).toBeGreaterThan(0); for (const occ of occurrences) { expect(occ.reminderLeadMinutes).toBe(60); } }); });