/** * RED test stubs for expandOccurrences() — Wave 0 state. * * These tests encode the concrete behavioral contracts that Plan 02 (GREEN phase) must satisfy. * All tests reference the not-yet-built module apps/api/src/broker/expand.ts and are expected * to fail until that module is implemented. * * Contracts locked 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. */ import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' // Not yet built — import will fail (RED state) until Plan 02 implements expand.ts import { expandOccurrences } from '../../src/broker/expand.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const FIXTURES = join(__dirname, '../fixtures') function loadFixture(name: string): string { return readFileSync(join(FIXTURES, name), 'utf8') } describe('expandOccurrences — RED stubs (Wave 0)', () => { 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 '#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 offset — before DST: offset is -05:00 // (so 10:00-05:00 = 15:00 UTC); after DST: offset is -04:00 (10:00-04:00 = 14:00 UTC). // Both are valid as long as the LOCAL wall-clock hour is 10. for (const occ of occurrences) { expect(occ.allDay).toBe(false) // start must be an offset-aware ISO string: '2026-03-01T10:00:00-05:00' or similar expect(occ.start).toMatch(/T10:00:00/) } // 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 offset -05:00 expect(preTransition!.start).toContain('T10:00:00') // Post-transition occurrence: EDT offset -04:00 expect(postTransition!.start).toContain('T10:00:00') }) }) 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, '#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, '#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() }) }) })