/** * RED test stubs for hydrateEvents() — Wave 0 state. * * These tests encode the concrete contracts that Plan 03 (GREEN phase) must satisfy. * The tests reference the not-yet-built module apps/pwa/src/lib/hydrateEvents.ts and * are expected to fail until that module is implemented. * * Contracts locked here: * 1. All-day occurrence (allDay:true, start 'YYYY-MM-DD') → start is Temporal.PlainDate * 2. Timed occurrence (allDay:false) → start is Temporal.ZonedDateTime * 3. Shared occurrence (isShared:true) → Schedule-X calendarId === 'shared' * 4. Personal occurrence (isShared:false, ownerUserId:7, calendarId:99) → * Schedule-X calendarId === '7' (String(ownerUserId)), NOT '99' (String(calendarId)) * This assertion locks the Plan 03 routing fix. */ import 'temporal-polyfill/global'; import { describe, it, expect } from 'vitest'; import { hydrateEvents } from './hydrateEvents.js'; // Minimal CalendarOccurrence shape for test purposes interface TestOccurrence { id: string; uid: string; calendarId: number; calendarName: string; ownerUserId: number; color: string; isShared: boolean; title: string; start: string; end: string; allDay: boolean; location: string | null; description: string | null; } function makeOccurrence(overrides: Partial): TestOccurrence { return { id: 'test::2026-06-15', uid: 'test-uid', calendarId: 1, calendarName: 'Test Calendar', ownerUserId: 1, color: '#4A90D9', isShared: false, title: 'Test Event', start: '2026-06-15T10:00:00-04:00[America/New_York]', end: '2026-06-15T11:00:00-04:00[America/New_York]', allDay: false, location: null, description: null, ...overrides, }; } describe('hydrateEvents — RED stubs (Wave 0)', () => { describe('Temporal type conversion', () => { it('converts all-day occurrence (allDay:true) to Temporal.PlainDate for start and end', () => { const occurrences = [ makeOccurrence({ allDay: true, start: '2026-06-15', end: '2026-06-15', }), ]; const result = hydrateEvents(occurrences); expect(result).toHaveLength(1); const evt = result[0]; // Schedule-X requires Temporal.PlainDate for all-day events expect(evt.start).toBeInstanceOf(Temporal.PlainDate); expect(evt.end).toBeInstanceOf(Temporal.PlainDate); }); it('converts an all-day exclusive DTEND to an inclusive last day for Schedule-X', () => { // Single-day event: iCal DTSTART:24 / DTEND:25 (exclusive). Schedule-X end is // inclusive, so a 1-day event must have start === end (renders on one day only). const single = hydrateEvents([ makeOccurrence({ allDay: true, start: '2026-06-24', end: '2026-06-25' }), ])[0]; expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24'); expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24'); // Two-day event: DTSTART:26 / DTEND:28 (exclusive) → inclusive last day = 27. const multi = hydrateEvents([ makeOccurrence({ allDay: true, start: '2026-06-26', end: '2026-06-28' }), ])[0]; expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26'); expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27'); }); it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => { const occurrences = [ makeOccurrence({ allDay: false, start: '2026-06-15T10:00:00-04:00[America/New_York]', end: '2026-06-15T11:00:00-04:00[America/New_York]', }), ]; const result = hydrateEvents(occurrences); expect(result).toHaveLength(1); const evt = result[0]; expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime); expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime); }); }); describe('calendarId routing — Plan 03 contract', () => { it('shared occurrence (isShared:true) gets Schedule-X calendarId "shared"', () => { const occurrences = [ makeOccurrence({ isShared: true, calendarId: 5, ownerUserId: 2, }), ]; const result = hydrateEvents(occurrences); expect(result).toHaveLength(1); // Shared-family events must route to the 'shared' calendar slot in Schedule-X config expect(result[0].calendarId).toBe('shared'); }); it('personal occurrence (isShared:false) gets Schedule-X calendarId === String(ownerUserId), NOT String(calendarId)', () => { // This assertion locks the routing decision: personal events are grouped by MEMBER (ownerUserId), // not by DB calendarId. If two calendars belong to the same user, they share the same color slot. const occurrences = [ makeOccurrence({ isShared: false, calendarId: 99, // DB calendar row id ownerUserId: 7, // DB user id — this is the correct Schedule-X key }), ]; const result = hydrateEvents(occurrences); expect(result).toHaveLength(1); // Must be '7' (String(ownerUserId)), NOT '99' (String(calendarId)) expect(result[0].calendarId).toBe('7'); expect(result[0].calendarId).not.toBe('99'); }); }); });