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>
223 lines
7.8 KiB
TypeScript
223 lines
7.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { toWallTime } from "@/lib/dates";
|
|
import {
|
|
contentChanged,
|
|
duplicateForm,
|
|
eventFromPayload,
|
|
formFromEvent,
|
|
isFormDirty,
|
|
newEventForm,
|
|
normaliseUrl,
|
|
payloadFromEvent,
|
|
setCalendar,
|
|
setStart,
|
|
toPayload,
|
|
validateEventForm,
|
|
type EventForm,
|
|
} from "@/lib/event-form";
|
|
import { makeEvent } from "@/lib/test-fixtures";
|
|
|
|
const now = new Date("2026-09-27T10:20:00Z"); // 12:20 in Berlin
|
|
|
|
const validForm = (overrides: Partial<EventForm> = {}): EventForm => ({
|
|
...formFromEvent(makeEvent()),
|
|
...overrides,
|
|
});
|
|
|
|
describe("newEventForm", () => {
|
|
it("starts at the next full Berlin hour, lasts two hours, and is a draft", () => {
|
|
const form = newEventForm(now, 2);
|
|
expect(form).toMatchObject({
|
|
calendarId: 2,
|
|
startDate: "2026-09-27",
|
|
startTime: "13:00",
|
|
endDate: "2026-09-27",
|
|
endTime: "15:00",
|
|
status: "DRAFT",
|
|
repeatFrequency: "",
|
|
});
|
|
});
|
|
|
|
it("makes a new birthday yearly", () => {
|
|
expect(newEventForm(now, 5).repeatFrequency).toBe("YEARLY");
|
|
});
|
|
|
|
it("leaves the calendar unchosen when none is given", () => {
|
|
expect(validateEventForm({ ...newEventForm(now), name: "x" }).calendarId).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("duplicateForm", () => {
|
|
it("copies the event as a new draft with a marked title", () => {
|
|
const form = duplicateForm(makeEvent({ name: "Probe", status: "PUBLIC" }));
|
|
expect(form.name).toBe("Probe (Kopie)");
|
|
expect(form.status).toBe("DRAFT");
|
|
expect(form.startTime).toBe("19:00");
|
|
});
|
|
});
|
|
|
|
describe("setStart", () => {
|
|
it("moves the end along, keeping a timed event's duration", () => {
|
|
const form = setStart(validForm(), "2026-10-10", "18:30");
|
|
expect(form).toMatchObject({ startDate: "2026-10-10", startTime: "18:30", endDate: "2026-10-10", endTime: "20:30" });
|
|
});
|
|
|
|
it("keeps the duration in real time across the DST switch", () => {
|
|
// 2 h starting 01:00 on 25 Oct: 01:00 CEST + 2 h = 02:00 CET.
|
|
const form = setStart(validForm(), "2026-10-25", "01:00");
|
|
expect(form).toMatchObject({ endDate: "2026-10-25", endTime: "02:00" });
|
|
});
|
|
|
|
it("keeps a whole-day event's length in days", () => {
|
|
const form = setStart(
|
|
validForm({ wholeDay: true, startDate: "2026-07-04", endDate: "2026-07-06" }),
|
|
"2026-08-01",
|
|
"",
|
|
);
|
|
expect(form).toMatchObject({ startDate: "2026-08-01", endDate: "2026-08-03" });
|
|
});
|
|
|
|
it("does not guess an end while the start is being cleared", () => {
|
|
const form = setStart(validForm(), "", "19:00");
|
|
expect(form).toMatchObject({ startDate: "", endDate: "2026-10-03" });
|
|
});
|
|
});
|
|
|
|
describe("setCalendar", () => {
|
|
it("makes an event yearly when it moves into Birthdays", () => {
|
|
expect(setCalendar(validForm(), 5).repeatFrequency).toBe("YEARLY");
|
|
});
|
|
|
|
it("drops the forced recurrence when it moves out of Birthdays", () => {
|
|
expect(setCalendar(validForm({ calendarId: 5, repeatFrequency: "YEARLY" }), 2).repeatFrequency).toBe("");
|
|
});
|
|
|
|
it("keeps a recurrence that other calendars' events already had", () => {
|
|
expect(setCalendar(validForm({ calendarId: 2, repeatFrequency: "WEEKLY" }), 4).repeatFrequency).toBe("WEEKLY");
|
|
});
|
|
});
|
|
|
|
describe("normaliseUrl", () => {
|
|
it.each([
|
|
["", ""],
|
|
[" ", ""],
|
|
["nachklang.art", "https://nachklang.art"],
|
|
["www.nachklang.art/konzert?x=1", "https://www.nachklang.art/konzert?x=1"],
|
|
["http://example.org", "http://example.org"],
|
|
["https://münchen.de", "https://münchen.de"],
|
|
])("accepts %j as %j", (raw, value) => {
|
|
expect(normaliseUrl(raw)).toEqual({ ok: true, value });
|
|
});
|
|
|
|
it.each(["nachklang", "javascript:alert(1)", "mailto:info@nachklang.art", "https://", "ht tp://x.de"])(
|
|
"rejects %j",
|
|
(raw) => {
|
|
expect(normaliseUrl(raw).ok).toBe(false);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("validateEventForm", () => {
|
|
it("accepts a complete event", () => {
|
|
expect(validateEventForm(validForm())).toEqual({});
|
|
});
|
|
|
|
it("requires a title and a calendar", () => {
|
|
const errors = validateEventForm(validForm({ name: " ", calendarId: 99 }));
|
|
expect(Object.keys(errors).sort()).toEqual(["calendarId", "name"]);
|
|
});
|
|
|
|
it("requires times only for timed events", () => {
|
|
expect(validateEventForm(validForm({ startTime: "" })).startTime).toBeDefined();
|
|
expect(validateEventForm(validForm({ wholeDay: true, startTime: "", endTime: "" }))).toEqual({});
|
|
});
|
|
|
|
it("rejects an end before the start, on the end field", () => {
|
|
expect(validateEventForm(validForm({ endTime: "18:00" })).endDate).toBe("Das Ende liegt vor dem Beginn.");
|
|
});
|
|
|
|
it("allows a one-day whole-day event whose hidden times would be backwards", () => {
|
|
expect(validateEventForm(validForm({ wholeDay: true, startTime: "22:00", endTime: "08:00" }))).toEqual({});
|
|
});
|
|
|
|
it("flags an unusable URL", () => {
|
|
expect(validateEventForm(validForm({ url: "kein link" })).url).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("toPayload", () => {
|
|
it("sends UTC instants of the Berlin wall times, and every field", () => {
|
|
const payload = toPayload(validForm({ name: " Konzert ", url: "nachklang.art" }));
|
|
expect(payload).toEqual({
|
|
calendarId: 1,
|
|
name: "Konzert",
|
|
description: "",
|
|
startDateTime: "2026-10-03T17:00:00.000Z",
|
|
endDateTime: "2026-10-03T19:00:00.000Z",
|
|
location: "Karlsruhe",
|
|
url: "https://nachklang.art",
|
|
wholeDay: false,
|
|
repeatFrequency: "",
|
|
status: "PUBLIC",
|
|
});
|
|
});
|
|
|
|
it("saves whole-day events as 00:00-23:59 Berlin, ignoring stale times", () => {
|
|
const payload = toPayload(
|
|
validForm({ wholeDay: true, startDate: "2026-12-24", endDate: "2026-12-26", startTime: "19:00", endTime: "21:00" }),
|
|
);
|
|
expect(toWallTime(new Date(payload.startDateTime))).toEqual({ date: "2026-12-24", time: "00:00" });
|
|
expect(toWallTime(new Date(payload.endDateTime))).toEqual({ date: "2026-12-26", time: "23:59" });
|
|
});
|
|
|
|
it("keeps the birthday recurrence and any other status", () => {
|
|
const payload = toPayload(validForm({ calendarId: 5, repeatFrequency: "YEARLY", status: "PRIVATE" }));
|
|
expect(payload).toMatchObject({ repeatFrequency: "YEARLY", status: "PRIVATE" });
|
|
});
|
|
});
|
|
|
|
describe("payloadFromEvent", () => {
|
|
it("resends the full event with only the status changed", () => {
|
|
const event = makeEvent({ status: "DRAFT", repeatFrequency: "YEARLY", calendarId: 5 });
|
|
expect(payloadFromEvent(event, "PUBLIC")).toMatchObject({
|
|
status: "PUBLIC",
|
|
repeatFrequency: "YEARLY",
|
|
calendarId: 5,
|
|
startDateTime: event.start.toISOString(),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("isFormDirty", () => {
|
|
it("notices any change and nothing else", () => {
|
|
const form = validForm();
|
|
expect(isFormDirty(form, { ...form })).toBe(false);
|
|
expect(isFormDirty(form, { ...form, location: "Mannheim" })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("contentChanged", () => {
|
|
it("ignores a change of calendar alone", () => {
|
|
const form = validForm();
|
|
expect(contentChanged(form, { ...form, calendarId: 4 })).toBe(false);
|
|
expect(contentChanged(form, { ...form, calendarId: 4, name: "Neu" })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("eventFromPayload", () => {
|
|
const now = new Date("2026-09-28T12:00:00Z");
|
|
|
|
it("keeps who created an edited event and records who changed it", () => {
|
|
const previous = makeEvent({ createdBy: "Anna", uuid: "u-1" });
|
|
const saved = eventFromPayload(1, toPayload(validForm({ name: "Neu" })), "Ben", now, previous);
|
|
expect(saved).toMatchObject({ eventId: 1, uuid: "u-1", name: "Neu", createdBy: "Anna", lastModifiedBy: "Ben" });
|
|
expect(saved.created).toBe(previous.created);
|
|
expect(saved.start.toISOString()).toBe("2026-10-03T17:00:00.000Z");
|
|
});
|
|
|
|
it("attributes a new event to the author", () => {
|
|
const saved = eventFromPayload(42, toPayload(validForm()), "Ben", now);
|
|
expect(saved).toMatchObject({ eventId: 42, createdBy: "Ben", created: now });
|
|
});
|
|
});
|