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 => ({ ...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 }); }); });