Files

159 lines
9.5 KiB
TypeScript

// 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";
import { demoLogin, isDemoDatabase } from "../src/lib/demo";
test("Demozugang nur bei explizitem Modus; Container-Reset nur fuer isolierte Demo-Datenbank", () => {
const normal = { NODE_ENV: "test" } as const;
assert.equal(demoLogin(normal), undefined);
assert.equal(demoLogin({ ...normal, DEMO_MODE: "false" }), undefined);
assert.equal(demoLogin({ ...normal, DEMO_MODE: "TRUE" }), undefined);
const demo = { ...normal, DEMO_MODE: "true", DEMO_RESET_ON_START: "true" };
assert.deepEqual(demoLogin(demo), { email: "demo@kibu.local", password: "KIBU-Demo-2026!" });
const target = new URL("postgresql://demo-db/kibu_demo");
assert.equal(isDemoDatabase(target, demo), true);
assert.equal(isDemoDatabase(target, normal), false);
assert.equal(isDemoDatabase(target, { ...demo, DEMO_RESET_ON_START: "false" }), false);
assert.equal(isDemoDatabase(new URL("postgresql://demo-db/kibu"), demo), false);
assert.equal(isDemoDatabase(new URL("postgresql://production/kibu_demo"), demo), false);
});
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<string, number>();
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);
});