2026-02-28 16:09:11 +00:00
|
|
|
|
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")
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-03-03 01:30:49 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|