Files
familysync/apps/api/tests/broker/expand.test.ts
T
Lucas Berger 194f6a82a8 fix(02): show owner name / Family in event popover footer
Backend:
- expand.ts: add ownerName: string | null to CalendarOccurrence
  interface and expandOccurrences() signature; thread it onto every
  emitted occurrence.
- events.ts: SELECT users.displayName as ownerName in the join; pass
  it to expandOccurrences().

Frontend:
- client.ts: add ownerName: string | null to CalendarOccurrence.
- EventDetailPopover.tsx: render isShared ? 'Family' :
  (ownerName ?? calendarName) in the footer instead of calendarName.

Tests:
- expand.test.ts: pass ownerName to all expandOccurrences() calls;
  assert ownerName is carried onto occurrences in the DST test.
- events.test.ts: add ownerName to mock rows; assert ownerName present
  on occurrences; add ownerName assertion to timed-recurring test.
- EventDetailPopover.test.tsx: add ownerName to fixtures; split
  "calendar name in footer" into three targeted tests covering
  personal-with-owner, shared→Family, and null-owner fallback.
2026-06-05 15:14:43 -04:00

225 lines
9.2 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).
*/
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 {
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('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')
}
})
})
})