import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchUpcomingPublicEvents, googleCalendarUrl, icalUrl, selectUpcoming, toGoogleCalendar, toWebcal, webcalUrl, } from "@/lib/public-calendar"; import type { ApiEvent } from "@/lib/types"; const now = new Date("2026-09-28T10:00:00Z"); const row = (eventId: number, start: string, end: string, name = `E${eventId}`): ApiEvent => ({ eventId, calendarId: 1, uuid: `u${eventId}`, name, description: "", startDateTime: start, endDateTime: end, createdDate: null, location: "", url: "", wholeDay: 0, repeatFrequency: "", }); afterEach(() => { vi.unstubAllGlobals(); }); describe("subscription links", () => { it("builds the iCal, webcal and Google links from the API URL", () => { expect(icalUrl("https://api.nachklang.art")).toBe("https://api.nachklang.art/calendar/events/public/ical"); expect(webcalUrl("https://api.nachklang.art")).toBe("webcal://api.nachklang.art/calendar/events/public/ical"); expect(webcalUrl("http://localhost:3000")).toBe("webcal://localhost:3000/calendar/events/public/ical"); // The same link the Angular landing page hardcoded. expect(googleCalendarUrl("https://api.nachklang.art")).toBe( "https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical", ); }); }); describe("links for a password-protected calendar", () => { it("keeps the password through the webcal and Google conversions", () => { const ical = "https://api.nachklang.art/calendar/events/choir/ical?password=a%26b"; expect(toWebcal(ical)).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b"); // Encoded once more as a whole, so Google hands back exactly the URL above. const cid = new URL(toGoogleCalendar(ical)).searchParams.get("cid"); expect(cid).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b"); }); }); describe("selectUpcoming", () => { it("keeps events not yet over, earliest first, up to the limit", () => { const rows = [ row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z"), row(2, "2026-07-01T18:00:00Z", "2026-07-01T20:00:00Z"), // over row(3, "2026-09-28T06:00:00Z", "2026-09-28T07:00:00Z"), // earlier today: still listed row(4, "2026-10-01T18:00:00Z", "2026-10-01T20:00:00Z"), ]; expect(selectUpcoming(rows, now, 2).map((e) => e.eventId)).toEqual([3, 4]); }); }); describe("fetchUpcomingPublicEvents", () => { it("returns null instead of throwing when the API is unreachable or unhappy", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed"))); await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 500 }))); await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull(); }); it("parses what the public feed returns", async () => { const fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify([row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z")]), { status: 200 }), ); vi.stubGlobal("fetch", fetch); const events = await fetchUpcomingPublicEvents("https://api.test", now, 3); expect(events?.map((e) => e.eventId)).toEqual([1]); expect(fetch).toHaveBeenCalledWith("https://api.test/calendar/events/public/json", { next: { revalidate: 300 } }); }); });