Files
aris/packages/aris-source-caldav/src/ical-parser.test.ts
kenneth d7d8dba2de fix(caldav): expand recurring events in range
The iCal parser returned master VEVENT components with their
original start dates instead of expanding recurrences. Events
from months ago appeared in today's feed.

parseICalEvents now accepts an optional timeRange. When set,
recurring events are expanded via ical.js iterator and only
occurrences overlapping the range are returned. Exception
overrides (RECURRENCE-ID) are applied during expansion.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-03 01:30:49 +00:00

199 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { parseICalEvents } from "./ical-parser.ts"
function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
}
describe("parseICalEvents", () => {
test("parses a full event with all fields", () => {
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("single-event-001@test")
expect(event.title).toBe("Team Standup")
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
expect(event.isAllDay).toBe(false)
expect(event.location).toBe("Conference Room A")
expect(event.description).toBe("Daily standup meeting")
expect(event.calendarName).toBe("Work")
expect(event.status).toBe("confirmed")
expect(event.url).toBe("https://example.com/meeting/123")
expect(event.organizer).toBe("Alice Smith")
expect(event.recurrenceId).toBeNull()
expect(event.attendees).toHaveLength(2)
expect(event.attendees[0]).toEqual({
name: "Bob Jones",
email: "bob@example.com",
role: "required",
status: "accepted",
})
expect(event.attendees[1]).toEqual({
name: "Carol White",
email: "carol@example.com",
role: "optional",
status: "tentative",
})
expect(event.alarms).toHaveLength(2)
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
})
test("parses an all-day event with optional fields as null", () => {
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Company Holiday")
expect(event.calendarName).toBeNull()
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
})
test("parses recurring event with exception", () => {
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
expect(events[0]!.uid).toBe("recurring-001@test")
expect(events[1]!.uid).toBe("recurring-001@test")
const base = events.find((e) => e.title === "Weekly Sync")
expect(base).toBeDefined()
expect(base!.recurrenceId).toBeNull()
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
expect(exception).toBeDefined()
expect(exception!.recurrenceId).not.toBeNull()
})
test("parses minimal event with defaults", () => {
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
expect(events).toHaveLength(1)
const event = events[0]!
expect(event.uid).toBe("minimal-001@test")
expect(event.title).toBe("Quick Chat")
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
expect(event.location).toBeNull()
expect(event.description).toBeNull()
expect(event.status).toBeNull()
expect(event.url).toBeNull()
expect(event.organizer).toBeNull()
expect(event.attendees).toEqual([])
expect(event.alarms).toEqual([])
expect(event.recurrenceId).toBeNull()
})
test("parses cancelled status", () => {
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
expect(events[0]!.status).toBe("cancelled")
})
})
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
test("expands weekly recurring event into occurrences within range", () => {
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
// Query window: Jan 14 Jan 23 → should get Jan 15 and Jan 22
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-23T00:00:00Z"),
})
expect(events).toHaveLength(2)
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
// All occurrences share the same UID and metadata
for (const event of events) {
expect(event.uid).toBe("weekly-001@test")
expect(event.title).toBe("Weekly Team Meeting")
expect(event.location).toBe("Room B")
expect(event.calendarName).toBe("Work")
}
})
test("returns empty array when no occurrences fall in range", () => {
// Query window: Dec 2025 — before the first occurrence
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
start: new Date("2025-12-01T00:00:00Z"),
end: new Date("2025-12-31T00:00:00Z"),
})
expect(events).toHaveLength(0)
})
test("applies exception overrides during expansion", () => {
// weekly-recurring-with-exception.ics:
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
// Query window: Jan 14 Jan 16 → should get the exception occurrence for Jan 15
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(events).toHaveLength(1)
expect(events[0]!.title).toBe("Standup (rescheduled)")
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
})
test("expands recurring all-day events", () => {
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
// Query window: Jan 14 Jan 17 → should get Jan 14, 15, 16
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
start: new Date("2026-01-14T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(events).toHaveLength(3)
for (const event of events) {
expect(event.isAllDay).toBe(true)
expect(event.title).toBe("Daily Reminder")
}
})
test("non-recurring events are filtered by range", () => {
// single-event.ics: 2026-01-15T14:00 15:00
// Query window that includes it
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-15T00:00:00Z"),
end: new Date("2026-01-16T00:00:00Z"),
})
expect(included).toHaveLength(1)
// Query window that excludes it
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
start: new Date("2026-01-16T00:00:00Z"),
end: new Date("2026-01-17T00:00:00Z"),
})
expect(excluded).toHaveLength(0)
})
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
// Legacy behavior: no expansion, just returns the VEVENT components as-is
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
expect(events).toHaveLength(2)
})
})