- non-recurring event: occurrence carries reminderLeadMinutes=30 from master - all-day event with 0-minute trigger: occurrence carries 0 (NULL-vs-0, D-06) - no VALARM: occurrence carries reminderLeadMinutes=null - D-10 series-level: all recurring occurrences inherit master's reminderLeadMinutes=60
494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
/**
|
|
* 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('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);
|
|
}
|
|
});
|
|
});
|