mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
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>
199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
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)
|
||
})
|
||
})
|