diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index b76e0a3..a80d333 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -1,23 +1,21 @@ /** - * RED test stubs for expandOccurrences() — Wave 0 state. + * Tests for expandOccurrences() — GREEN 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: + * 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). */ +import 'temporal-polyfill/global' 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)) @@ -27,7 +25,7 @@ function loadFixture(name: string): string { return readFileSync(join(FIXTURES, name), 'utf8') } -describe('expandOccurrences — RED stubs (Wave 0)', () => { +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', () => { @@ -56,13 +54,14 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => { 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. + // 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) - // start must be an offset-aware ISO string: '2026-03-01T10:00:00-05:00' or similar + // 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) @@ -72,10 +71,12 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => { expect(preTransition).toBeDefined() expect(postTransition).toBeDefined() - // Pre-transition occurrence: EST offset -05:00 + // Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]' expect(preTransition!.start).toContain('T10:00:00') - // Post-transition occurrence: EDT offset -04: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]') }) }) @@ -138,4 +139,43 @@ describe('expandOccurrences — RED stubs (Wave 0)', () => { expect(june15).toBeUndefined() }) }) + + 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, + '#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') + } + }) + }) })