import { describe, expect, it } from "vitest"; import { generateAppointmentIcs } from "../ics"; const BASE_INPUT = { appointmentId: "appt_123", businessName: "Blake Heritage Barbershop", businessAddress: "79 Joel Street, HA6 1LW", staffName: "Marcus Reed", serviceNames: ["Haircut & Style"], startAt: new Date("2026-08-10T09:00:00.000Z"), endAt: new Date("2026-08-10T09:30:00.000Z"), }; describe("generateAppointmentIcs", () => { it("produces a well-formed VCALENDAR/VEVENT block", () => { const ics = generateAppointmentIcs(BASE_INPUT); expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/); expect(ics).toContain("BEGIN:VEVENT\r\n"); expect(ics).toContain("END:VEVENT\r\n"); expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/); }); it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => { const ics = generateAppointmentIcs(BASE_INPUT); expect(ics).toContain("DTSTART:20260810T090000Z"); expect(ics).toContain("DTEND:20260810T093000Z"); }); it("includes a UID derived from the appointment id, stable across re-generation", () => { const first = generateAppointmentIcs(BASE_INPUT); const second = generateAppointmentIcs(BASE_INPUT); const uidPattern = /UID:appt_123@nextchair/; expect(first).toMatch(uidPattern); expect(second).toMatch(uidPattern); }); it("combines multiple service names in the summary and description", () => { const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] }); expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop"); expect(ics).toContain("Services: Haircut & Style\\, Beard Trim"); }); it("escapes commas, semicolons, and backslashes per RFC 5545", () => { const ics = generateAppointmentIcs({ ...BASE_INPUT, businessName: "Sam's Cuts; Trims, & More\\Co", }); // Escaped backslash first, then semicolon, then comma — order matters so // the escaping backslashes themselves don't get re-escaped. expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co"); }); it("escapes newlines in notes as literal \\n rather than breaking the content line", () => { const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" }); expect(ics).toContain("Line one\\nLine two"); // No raw, unescaped newline should appear inside the DESCRIPTION value itself. const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION")); expect(descriptionLine).toBeDefined(); }); it("omits the Notes line entirely when there are no notes", () => { const ics = generateAppointmentIcs(BASE_INPUT); expect(ics).not.toContain("Notes:"); }); it("falls back to just the business name when no address is given", () => { const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined }); expect(ics).toContain("LOCATION:Blake Heritage Barbershop"); }); it("folds content lines longer than 75 octets per RFC 5545", () => { const longNotes = "N".repeat(200); const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes }); const rawLines = ics.split("\r\n"); // No single physical line (before folding continuation) should exceed 75 octets. for (const line of rawLines) { expect(line.length).toBeLessThanOrEqual(75); } // Continuation lines start with a single space, per the spec. const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION")); expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true); }); it("uses CRLF line endings throughout", () => { const ics = generateAppointmentIcs(BASE_INPUT); expect(ics.includes("\r\n")).toBe(true); // Every bare \n should be immediately preceded by \r. for (let i = 0; i < ics.length; i++) { if (ics[i] === "\n") { expect(ics[i - 1]).toBe("\r"); } } }); });