Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.4 KiB
TypeScript
86 lines
3.4 KiB
TypeScript
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 } });
|
|
});
|
|
});
|