feat(02-01): schema columns, PWA vitest harness, ICS fixtures, RED test stubs
- Add calendarEvents.hasRrule boolean + idx_calendar_events_has_rrule index (Phase 2 pre-filter) - Add calendars.isShared boolean for shared-family calendar identification - Create apps/pwa/vitest.config.ts with jsdom environment - Add vitest, @testing-library/react, jsdom, @testing-library/jest-dom to PWA devDependencies - Add "test": "vitest run" script to apps/pwa/package.json - Create three ICS fixtures: weekly-dst.ics (DST spanning), allday-birthday.ics, exdate-series.ics - Create RED test stub expand.test.ts with concrete DST wall-clock assertions (10:00 local both sides of March 2026 boundary) - Create RED test stub events.test.ts with 400 validation and color/isShared field contracts - Create RED test stub hydrateEvents.test.ts with Temporal type and calendarId routing contracts (shared→"shared", personal→String(ownerUserId)) - Create RED test stub calendarConfig.test.ts with firstDayOfWeek 0→7 translation contract
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//FamilySync//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:birthday-annual@familysync.test
|
||||
DTSTART;VALUE=DATE:20260615
|
||||
RRULE:FREQ=YEARLY
|
||||
SUMMARY:Birthday
|
||||
DESCRIPTION:Annual birthday event - all-day, no time component
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//FamilySync//Test//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19701101T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
TZNAME:EST
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19700308T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
TZNAME:EDT
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-exdate-series@familysync.test
|
||||
DTSTART;TZID=America/New_York:20260601T090000
|
||||
RRULE:FREQ=WEEKLY;COUNT=5
|
||||
EXDATE;TZID=America/New_York:20260615T090000
|
||||
SUMMARY:Weekly Series With Exdate
|
||||
DESCRIPTION:Weekly series with the June 15 occurrence excluded via EXDATE
|
||||
DURATION:PT30M
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//FamilySync//Test//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19701101T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
TZNAME:EST
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19700308T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
TZNAME:EDT
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-meeting-dst@familysync.test
|
||||
DTSTART;TZID=America/New_York:20260301T100000
|
||||
RRULE:FREQ=WEEKLY
|
||||
SUMMARY:Weekly Meeting DST Test
|
||||
DESCRIPTION:Weekly meeting spanning the March 2026 America/New_York EST to EDT transition
|
||||
DURATION:PT1H
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* RED test stubs for GET /api/events — Wave 0 state.
|
||||
*
|
||||
* These tests encode the contract for the evolved windowed /api/events route.
|
||||
* They reference the current events route which does not yet support windowed queries,
|
||||
* color joins, or the isShared flag — all tests are expected to fail (RED) until Plan 02.
|
||||
*
|
||||
* Contracts locked here:
|
||||
* 1. Missing/malformed start or end params → 400 (input validation guard)
|
||||
* 2. Valid window returns occurrences each with a color field and isShared flag
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock DB to avoid real DB connections in unit tests
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
|
||||
it('returns 400 when start param is missing', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const res = await app.request('/api/events?end=2026-07-01')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when end param is missing', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const res = await app.request('/api/events?start=2026-06-01')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when start param is malformed (not YYYY-MM-DD)', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const res = await app.request('/api/events?start=not-a-date&end=2026-07-01')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns occurrences with color and isShared fields for valid window', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const res = await app.request('/api/events?start=2026-06-01&end=2026-07-01')
|
||||
// Route not yet evolved — currently returns raw events without windowing or color join.
|
||||
// This test will pass once Plan 02 implements the windowed query + expansion.
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { occurrences: Array<{ color: string; isShared: boolean }> }
|
||||
expect(body).toHaveProperty('occurrences')
|
||||
expect(Array.isArray(body.occurrences)).toBe(true)
|
||||
// Each occurrence must carry color + isShared (may be empty array if DB is mocked empty)
|
||||
for (const occ of body.occurrences) {
|
||||
expect(occ).toHaveProperty('color')
|
||||
expect(typeof occ.color).toBe('string')
|
||||
expect(occ).toHaveProperty('isShared')
|
||||
expect(typeof occ.isShared).toBe('boolean')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user