// SPDX-FileCopyrightText: 2026 Paul Schoeneck // // SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms import { test } from "node:test"; import assert from "node:assert/strict"; import { localClock, localInstant, pickupState } from "../src/lib/time"; import { childSchema, correctionSchema, daySchema, overrideSchema, aloneRulesSchema, contactSchema, slotSchema, applicationSchema } from "../src/lib/validation"; import { hashPassword, verifyPassword } from "../src/lib/password"; import { chooseWinners, invitationHash, newInvitation } from "../src/lib/admissions"; test("Abholfenster: genau jetzt und bis einschließlich 15 Minuten, überfällig erst davor", () => { assert.equal(pickupState(null, "14:00"), "none"); assert.equal(pickupState("13:59", "14:00"), "overdue"); assert.equal(pickupState("14:00", "14:00"), "due"); assert.equal(pickupState("14:15", "14:00"), "due"); assert.equal(pickupState("14:16", "14:00"), "later"); assert.equal(pickupState("10:05", "09:59"), "due"); }); test("Berliner Tag und Wochentag folgen der Zeitzone, einschließlich Sommerzeit", () => { const previous = process.env.APP_TIMEZONE; process.env.APP_TIMEZONE = "Europe/Berlin"; try { assert.deepEqual(localClock(new Date("2026-11-02T23:30:00Z")), { day: "2026-11-03", time: "00:30", weekday: 2 }); assert.deepEqual(localClock(new Date("2026-07-05T22:30:00Z")), { day: "2026-07-06", time: "00:30", weekday: 1 }); assert.deepEqual(localClock(new Date("2026-11-01T10:00:00Z")), { day: "2026-11-01", time: "11:00", weekday: 7 }); } finally { if (previous === undefined) delete process.env.APP_TIMEZONE; else process.env.APP_TIMEZONE = previous; } }); test("Nur echte Kalendertage werden akzeptiert", () => { for (const date of ["2026-02-29", "2026-02-30", "2026-13-01", "oops", "1999-12-31", "2101-01-01"]) { assert.equal(daySchema.safeParse(date).success, false, date); } assert.equal(daySchema.safeParse("2028-02-29").success, true); }); test("Kinder benötigen Namen und Klasse; Wochenzeiten sind eindeutig und chronologisch", () => { const child = { firstName: " Emma ", lastName: "Test", className: "2a", schedules: [{ weekday: 1, arrival: "12:00", pickup: "16:00" }] }; assert.equal(childSchema.parse(child).firstName, "Emma"); assert.equal(childSchema.safeParse({ ...child, firstName: " " }).success, false); assert.equal(childSchema.safeParse({ ...child, schedules: [child.schedules[0], child.schedules[0]] }).success, false); assert.equal(childSchema.safeParse({ ...child, schedules: [{ weekday: 1, arrival: "16:00", pickup: "12:00" }] }).success, false); assert.equal(childSchema.safeParse({ ...child, schedules: [{ weekday: 1, arrival: "24:00", pickup: "25:00" }] }).success, false); assert.equal(childSchema.safeParse({ ...child, schedules: [] }).success, true); }); test("Tagesabweichungen verlangen nur bei individuellen Zeiten eine gültige Zeitspanne", () => { const override = { childId: "623548a9-d792-4a46-9554-543fd7f938ae", day: "2026-11-03", mode: "custom", arrival: "12:00", pickup: "16:00" }; assert.equal(overrideSchema.safeParse(override).success, true); assert.equal(overrideSchema.safeParse({ ...override, pickup: "11:00" }).success, false); assert.equal(overrideSchema.safeParse({ ...override, arrival: "", pickup: "" }).success, false); assert.equal(overrideSchema.safeParse({ ...override, mode: "absent", arrival: "", pickup: "" }).success, true); assert.equal(overrideSchema.safeParse({ ...override, mode: "weekly", arrival: "", pickup: "" }).success, true); }); test("Passwörter werden gesalzen gehasht und nur korrektes Passwort akzeptiert", async () => { const password = "test-only-password-not-a-credential"; const hash = await hashPassword(password); assert.notEqual(hash, password); assert.notEqual(hash, await hashPassword(password)); assert.equal(await verifyPassword(password, hash), true); assert.equal(await verifyPassword("wrong-password", hash), false); assert.equal(await verifyPassword(password, "broken"), false); }); test("Korrekturen benötigen Grund, passenden Status und chronologische Zeiten", () => { const value = { childId: "623548a9-d792-4a46-9554-543fd7f938ae", day: "2026-11-02", status: "picked_up", arrival: "12:00", pickup: "15:00", pickupKind: "alone", reason: "Abholung vergessen", expectedVersion: "1" }; assert.equal(correctionSchema.safeParse(value).success, true); for (const change of [{ reason: " " }, { pickup: "11:00" }, { arrival: "" }, { status: "sick" }, { status: "arrived" }, { status: "expected" }, { expectedVersion: "x" }]) { assert.equal(correctionSchema.safeParse({ ...value, ...change }).success, false, JSON.stringify(change)); } assert.equal(correctionSchema.safeParse({ ...value, status: "expected", arrival: "", pickup: "", expectedVersion: null }).success, true); }); test("Abholkontakte und eindeutige Alleingehregeln werden validiert", () => { const childId = "623548a9-d792-4a46-9554-543fd7f938ae"; assert.equal(contactSchema.safeParse({ childId, name: "Testperson", relationship: "Oma", phone: "", notes: "", authorized: false }).success, true); assert.equal(aloneRulesSchema.safeParse({ childId, rules: [] }).success, true); assert.equal(aloneRulesSchema.safeParse({ childId, rules: [{ weekday: 1, earliest: "15:00" }, { weekday: 1, earliest: "16:00" }] }).success, false); assert.equal(aloneRulesSchema.safeParse({ childId, rules: [{ weekday: 8, earliest: "15:00" }] }).success, false); }); test("Korrekturzeiten folgen Berlin und lehnen mehrdeutige/nicht existente DST-Zeiten ab", () => { assert.equal(localInstant("2026-11-02", "15:10", "Europe/Berlin").toISOString(), "2026-11-02T14:10:00.000Z"); assert.equal(localInstant("2026-07-02", "15:10", "Europe/Berlin").toISOString(), "2026-07-02T13:10:00.000Z"); assert.throws(() => localInstant("2026-03-29", "02:30", "Europe/Berlin"), /Zeitumstellung/); assert.throws(() => localInstant("2026-10-25", "02:30", "Europe/Berlin"), /Zeitumstellung/); }); test("Abholslots werden erst nach dem Fenster überfällig, Vorlauf einschließlich 15 Minuten", () => { assert.equal(pickupState("15:00", "14:44", "15:15"), "later"); assert.equal(pickupState("15:00", "14:45", "15:15"), "due"); assert.equal(pickupState("15:00", "15:00", "15:15"), "due"); assert.equal(pickupState("15:00", "15:10", "15:15"), "due"); assert.equal(pickupState("15:00", "15:15", "15:15"), "due"); assert.equal(pickupState("15:00", "15:16", "15:15"), "overdue"); assert.equal(slotSchema.safeParse({ start: "15:00", end: "15:15" }).success, true); assert.equal(slotSchema.safeParse({ start: "15:00", end: "15:00" }).success, false); assert.equal(slotSchema.safeParse({ start: "15:00", end: "14:00" }).success, false); }); test("Auslosung wählt ohne Zurücklegen exakt X und verändert Eingaben nicht", () => { const candidates = ["a", "b", "c", "d"]; assert.deepEqual(chooseWinners(candidates, 2, (min) => min), ["a", "b"]); assert.deepEqual(chooseWinners(candidates, 2, (_min, max) => max - 1), ["d", "a"]); assert.deepEqual(candidates, ["a", "b", "c", "d"]); assert.equal(new Set(chooseWinners(candidates, 4)).size, 4); for (const places of [0, -1, 5, 1.5, NaN]) assert.throws(() => chooseWinners(candidates, places)); assert.throws(() => chooseWinners([], 1)); }); test("Alle Zweierauswahlen aus drei Kindern sind im Auswahlalgorithmus gleichberechtigt", () => { const counts = new Map(); for (let first = 0; first < 3; first++) { for (let second = 1; second < 3; second++) { let step = 0; const selected = chooseWinners(["a", "b", "c"], 2, () => step++ === 0 ? first : second).sort().join(""); counts.set(selected, (counts.get(selected) ?? 0) + 1); } } assert.deepEqual([...counts.entries()].sort(), [["ab", 2], ["ac", 2], ["bc", 2]]); }); test("Einladungscode ist zufällig und Elternanmeldung benötigt vollständige Angaben und Bestätigung", () => { const code = newInvitation(); assert.match(code, /^[a-f0-9]{48}$/); assert.notEqual(newInvitation(), code); assert.notEqual(invitationHash(code), code); const application = { code, firstName: " Test ", lastName: "Anmeldung", className: "1a", parentName: "Testkontakt", email: "DEMO@example.invalid", notes: "", consent: true }; assert.equal(applicationSchema.parse(application).email, "demo@example.invalid"); assert.equal(applicationSchema.parse(application).firstName, "Test"); assert.equal(applicationSchema.safeParse({ ...application, consent: false }).success, false); assert.equal(applicationSchema.safeParse({ ...application, code: "short" }).success, false); });