Files
Calendar_Frontend/lib/event-form.test.ts
T
PaddyandClaude Opus 5.5 0cda40f85e Rewrite the calendar frontend in Next.js
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>
2026-09-28 22:15:59 +02:00

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 });
});
});