From 593302ee41089c2b8f514b4c2b9228d422279f00 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 11:11:08 -0400 Subject: [PATCH] test(06-03): add failing tests for hasRrule + bounded expansion - Add hasRrule===true assertion for recurring events (weekly-dst.ics) - Add hasRrule===false assertion for non-recurring events (single-duration.ics) - Add weekly-count3.ics fixture (FREQ=WEEKLY;COUNT=3, 1-hour events) - Add bounded RRULE test: expects exactly 3 occurrences in wide window - Add per-occurrence duration test: each occurrence is 1 hour (not recurrence span) - Tests are RED: hasRrule field absent from CalendarOccurrence interface --- apps/api/tests/broker/expand.test.ts | 128 ++++++++++++++++++++++ apps/api/tests/fixtures/weekly-count3.ics | 12 ++ 2 files changed, 140 insertions(+) create mode 100644 apps/api/tests/fixtures/weekly-count3.ics diff --git a/apps/api/tests/broker/expand.test.ts b/apps/api/tests/broker/expand.test.ts index 6d5d130..648109c 100644 --- a/apps/api/tests/broker/expand.test.ts +++ b/apps/api/tests/broker/expand.test.ts @@ -8,6 +8,9 @@ * 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' @@ -182,6 +185,131 @@ describe('expandOccurrences', () => { }) }) + 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 diff --git a/apps/api/tests/fixtures/weekly-count3.ics b/apps/api/tests/fixtures/weekly-count3.ics new file mode 100644 index 0000000..7a11d3b --- /dev/null +++ b/apps/api/tests/fixtures/weekly-count3.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//FamilySync//Test//EN +BEGIN:VEVENT +UID:weekly-count3@familysync.test +DTSTART:20260601T090000Z +DTEND:20260601T100000Z +RRULE:FREQ=WEEKLY;COUNT=3 +SUMMARY:Weekly Count 3 +DESCRIPTION:Bounded weekly series — exactly 3 occurrences (COUNT=3); each occurrence duration is 1 hour from DTSTART→DTEND +END:VEVENT +END:VCALENDAR