test: Demodaten und Unit- sowie PostgreSQL-Tests ergänzen

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul SchoeneckandCopilot committed 2026-11-03 08:01:25 +01:00
1 parent c1513c040b
commit ef52cc1218
3 files changed
+519

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
import "dotenv/config";
import { after, test } from "node:test";
import assert from "node:assert/strict";
import { db } from "../src/lib/db";
import { getChildren } from "../src/lib/data";
import { applyCorrection } from "../src/lib/corrections";
import { applicationSchema, correctionSchema } from "../src/lib/validation";
import { auditContext } from "../src/lib/audit";
import { getAuditEntries } from "../src/lib/profile";
import { acceptApplication, drawRound, invitationHash, newInvitation, submitApplication } from "../src/lib/admissions";
import { resolvePickup } from "../src/lib/slots";
after(() => db.end());
test("PostgreSQL: Wochenplan, Tagesabweichung, Anwesenheit und Persistenz", async () => {
let childId: string | undefined;
let staffId: string | undefined;
const client = await db.connect();
try {
await client.query("BEGIN");
const staff = await client.query<{ id: string }>(
"INSERT INTO staff(email, name, password_hash) VALUES ('integration-' || gen_random_uuid()::text || '@example.invalid', 'Integrationstest', 'not-a-login') RETURNING id");
staffId = staff.rows[0].id;
const result = await client.query<{ id: string }>(
"INSERT INTO children(first_name, last_name, class_name) VALUES ('Integration', 'Testkind', 'TEST') RETURNING id");
childId = result.rows[0].id;
await client.query("INSERT INTO weekly_schedules(child_id, weekday, arrival, pickup) VALUES ($1, 2, '12:00', '16:00'), ($1, 3, '13:00', '17:00')", [childId]);
const read = async (day: string) => (await getChildren(day, client, childId))[0];
assert.equal((await read("2026-11-03")).pickup, "16:00");
assert.equal((await read("2026-11-04")).pickup, "17:00");
assert.equal((await read("2026-11-05")).pickup, null);
await client.query("INSERT INTO day_overrides(child_id, day, attending, arrival, pickup) VALUES ($1, '2026-11-03', true, '11:30', '14:00')", [childId]);
const custom = await read("2026-11-03");
assert.equal(custom.arrival, "11:30");
assert.equal(custom.pickup, "14:00");
assert.equal(custom.overrideMode, "custom");
assert.equal(custom.schedules.length, 2);
await client.query("UPDATE day_overrides SET attending = false, arrival = NULL, pickup = NULL WHERE child_id = $1", [childId]);
assert.equal((await read("2026-11-03")).pickup, null);
assert.equal((await read("2026-11-03")).overrideMode, "absent");
await client.query("DELETE FROM day_overrides WHERE child_id = $1", [childId]);
assert.equal((await read("2026-11-03")).pickup, "16:00");
await client.query("INSERT INTO attendance(child_id, day, status, arrived_at, updated_by) VALUES ($1, '2026-11-03', 'arrived', now(), $2)", [childId, staffId]);
assert.equal((await read("2026-11-03")).status, "arrived");
assert.equal((await read("2026-11-04")).status, "expected");
await client.query("UPDATE attendance SET status = 'picked_up', picked_up_at = now(), pickup_kind = 'parents' WHERE child_id = $1", [childId]);
const picked = await read("2026-11-03");
assert.equal(picked.status, "picked_up");
assert.equal(picked.pickupKind, "parents");
assert.ok(picked.arrivedAt && picked.pickedUpAt);
await client.query("SAVEPOINT invalid");
await assert.rejects(client.query("INSERT INTO weekly_schedules(child_id, weekday, arrival, pickup) VALUES ($1, 4, '17:00', '12:00')", [childId]), /check constraint/);
await client.query("ROLLBACK TO invalid");
await assert.rejects(client.query("UPDATE attendance SET status = 'sick' WHERE child_id = $1", [childId]), /check constraint/);
} finally {
await client.query("ROLLBACK");
client.release();
}
});
test("Korrekturen sind atomar, versioniert und vollständig protokolliert", async () => {
const client = await db.connect();
try {
await client.query("BEGIN");
const staff = await client.query<{ id: string }>("SELECT id FROM staff LIMIT 1");
const staffId = staff.rows[0].id;
await auditContext(client, staffId, "Integrationstest");
const child = await client.query<{ id: string }>("INSERT INTO children(first_name, last_name, class_name) VALUES ('Test', 'Korrektur', 'TEST') RETURNING id");
const id = child.rows[0].id;
const input = correctionSchema.parse({ childId: id, day: "2026-11-02", status: "arrived",
arrival: "12:00", pickup: "", pickupKind: "planned", reason: "Ankunft nachgetragen", expectedVersion: null });
const now = new Date("2026-11-03T18:00:00Z");
await applyCorrection(client, staffId, input, now);
const read = async () => (await client.query<{ version: string; status: string }>(
"SELECT version::text, status FROM attendance WHERE child_id = $1", [id])).rows[0];
const first = await read();
await assert.rejects(applyCorrection(client, staffId, input, now), /inzwischen geändert/);
await applyCorrection(client, staffId, { ...input, status: "picked_up", pickup: "15:10", pickupKind: "alone",
reason: "Vergessenen Checkout nachgetragen", expectedVersion: first.version }, now);
const second = await read();
assert.notEqual(first.version, second.version);
assert.equal(second.status, "picked_up");
const logs = await client.query<{ actor_id: string; reason: string; before_data: { status: string }; after_data: { status: string; picked_up_at: string } }>(
"SELECT actor_id, reason, before_data, after_data FROM audit_log WHERE child_id = $1 AND entity = 'attendance' ORDER BY id", [id]);
assert.equal(logs.rowCount, 2);
assert.equal(logs.rows[1].actor_id, staffId);
assert.equal(logs.rows[1].reason, "Vergessenen Checkout nachgetragen");
assert.equal(logs.rows[1].before_data.status, "arrived");
assert.equal(logs.rows[1].after_data.status, "picked_up");
assert.equal(new Date(logs.rows[1].after_data.picked_up_at).toISOString(), "2026-11-02T14:10:00.000Z");
await assert.rejects(applyCorrection(client, staffId, { ...input, day: "2026-11-04" }, now), /Zukünftige/);
await assert.rejects(applyCorrection(client, staffId, { ...input, day: "2026-11-03", arrival: "23:59" }, now), /Zukunft/);
await client.query("SAVEPOINT immutable");
await assert.rejects(client.query("UPDATE audit_log SET reason = 'tamper' WHERE child_id = $1", [id]), /unveraenderlich/);
await client.query("ROLLBACK TO immutable");
await assert.rejects(client.query("DELETE FROM audit_log WHERE child_id = $1", [id]), /unveraenderlich/);
await client.query("ROLLBACK TO immutable");
await applyCorrection(client, staffId, { ...input, status: "expected", arrival: "", pickup: "",
expectedVersion: second.version, reason: "Falschen Betreuungstag zurückgesetzt" }, now);
assert.equal(await read(), undefined);
const audit = await client.query("SELECT id FROM audit_log WHERE child_id = $1 AND operation = 'DELETE' AND entity = 'attendance'", [id]);
assert.equal(audit.rowCount, 1);
await applyCorrection(client, staffId, { ...input, expectedVersion: null }, now);
assert.notEqual((await read()).version, first.version);
} finally {
await client.query("ROLLBACK");
client.release();
}
});
test("Abholberechtigungen und Alleingehregeln wirken im Dashboard; Klassenwechsel erhält Verlauf", async () => {
const client = await db.connect();
try {
await client.query("BEGIN");
const result = await client.query<{ id: string }>("INSERT INTO children(first_name, last_name, class_name) VALUES ('Test', 'Berechtigung', '1a') RETURNING id");
const id = result.rows[0].id;
await client.query(`INSERT INTO pickup_contacts(child_id, name, relationship, authorized) VALUES
($1, 'Erlaubte Testperson', 'Mutter', true), ($1, 'Gesperrte Testperson', 'Vater', false)`, [id]);
await client.query("INSERT INTO alone_rules(child_id, weekday, earliest) VALUES ($1, 2, '15:00')", [id]);
const read = async (day = "2026-11-03") => (await getChildren(day, client, id))[0];
assert.equal((await read()).aloneFrom, "15:00");
assert.equal((await read("2026-11-04")).aloneFrom, null);
assert.deepEqual((await read()).contacts.map((c) => c.name), ["Erlaubte Testperson"]);
await client.query("UPDATE children SET class_name = '2a' WHERE id = $1", [id]);
assert.equal((await read()).className, "2a");
assert.equal((await read()).id, id);
const count = await client.query<{ count: number }>("SELECT count(*)::int FROM audit_log WHERE child_id = $1 AND entity = 'children'", [id]);
assert.equal(count.rows[0].count, 2);
await client.query("UPDATE children SET class_name = '2a' WHERE id = $1", [id]);
const unchanged = await client.query<{ count: number }>("SELECT count(*)::int FROM audit_log WHERE child_id = $1 AND entity = 'children'", [id]);
assert.equal(unchanged.rows[0].count, 2);
await client.query("UPDATE pickup_contacts SET authorized = false WHERE child_id = $1", [id]);
assert.equal((await read()).contacts.length, 0);
await client.query("DELETE FROM alone_rules WHERE child_id = $1", [id]);
assert.equal((await read()).aloneFrom, null);
} finally {
await client.query("ROLLBACK");
client.release();
}
});
test("Profil paginiert das Audit-Log numerisch statt lexikografisch", async () => {
const client = await db.connect();
try {
await client.query("BEGIN");
const child = await client.query<{ id: string }>("INSERT INTO children(first_name, last_name, class_name) VALUES ('Test', 'Reihenfolge', 'TEST') RETURNING id");
const id = child.rows[0].id;
await client.query(`INSERT INTO audit_log(id, child_id, entity, operation, actor_name, reason)
OVERRIDING SYSTEM VALUE VALUES (9000000000000000, $1, 'children', 'UPDATE', 'Test', 'Älterer Test'),
(10000000000000000, $1, 'children', 'UPDATE', 'Test', 'Neuerer Test')`, [id]);
const audit = await getAuditEntries(id, 1, client);
assert.equal(audit[0].id, "10000000000000000");
assert.equal(audit[1].id, "9000000000000000");
const next = await getAuditEntries(id, 2, client);
assert.equal(next.length, 0);
} finally {
await client.query("ROLLBACK");
client.release();
}
});
test("Slots werden serverseitig aufgelöst, Fenster und Referenz bleiben im Plan erhalten", async () => {
const client = await db.connect();
try {
await client.query("BEGIN");
const slot = await client.query<{ id: string }>("SELECT id FROM pickup_slots WHERE starts_at = '15:00' AND ends_at = '15:15'");
const slotId = slot.rows[0].id;
const resolved = await resolvePickup(client, "12:00", "23:59", slotId);
assert.deepEqual(resolved, { pickup: "15:00", end: "15:15", slotId });
await assert.rejects(resolvePickup(client, "16:00", "23:59", slotId), /vor dem Abholslot/);
await assert.rejects(resolvePickup(client, "12:00", "11:00", null), /nach der Ankunft/);
assert.deepEqual(await resolvePickup(client, "12:00", "15:37", null), { pickup: "15:37", end: null, slotId: null });
const child = await client.query<{ id: string }>("INSERT INTO children(first_name, last_name, class_name) VALUES ('Slot', 'Test', 'TEST') RETURNING id");
const id = child.rows[0].id;
await client.query(`INSERT INTO weekly_schedules(child_id, weekday, arrival, pickup, pickup_end, pickup_slot_id)
VALUES ($1, 2, '12:00', '15:00', '15:15', $2)`, [id, slotId]);
const read = async () => (await getChildren("2026-11-03", client, id))[0];
assert.equal((await read()).pickupEnd, "15:15");
assert.equal((await read()).slotId, slotId);
assert.equal((await read()).schedules[0].slotId, slotId);
await client.query("UPDATE pickup_slots SET active = false WHERE id = $1", [slotId]);
assert.equal((await read()).pickupEnd, "15:15");
await client.query(`INSERT INTO day_overrides(child_id, day, attending, arrival, pickup)
VALUES ($1, '2026-11-03', true, '12:00', '15:37')`, [id]);
assert.equal((await read()).pickup, "15:37");
assert.equal((await read()).pickupEnd, null);
assert.equal((await read()).slotId, null);
} finally { await client.query("ROLLBACK"); client.release(); }
});
test("Aufnahme: Anmeldung, Sperre gegen Duplikate, einmalige Auslosung und bestätigte Übernahme", async () => {
const client = await db.connect();
try {
await client.query("BEGIN");
const staff = await client.query<{ id: string }>("SELECT id FROM staff LIMIT 1");
const staffId = staff.rows[0].id;
const code = newInvitation();
const round = await client.query<{ id: string }>(`INSERT INTO admission_rounds(name, code_hash, expires_on, created_by)
VALUES ('Integrationstest', $1, '2100-12-31', $2) RETURNING id`, [invitationHash(code), staffId]);
const roundId = round.rows[0].id;
const input = applicationSchema.parse({ code, firstName: "Demo", lastName: "Test", className: "1a",
parentName: "Testkontakt", email: "demo@example.invalid", notes: "Test", consent: true });
await submitApplication(client, input);
await assert.rejects(submitApplication(client, input), /bereits eine Anmeldung/);
await assert.rejects(submitApplication(client, { ...input, code: newInvitation() }), /ungültig/);
for (let index = 1; index < 5; index++) await submitApplication(client, { ...input, firstName: `Demo ${index}` });
await assert.rejects(drawRound(client, roundId, 2, staffId), /schließen/);
await client.query("UPDATE admission_rounds SET status = 'closed' WHERE id = $1", [roundId]);
await assert.rejects(submitApplication(client, { ...input, firstName: "Zu spät" }), /geschlossen/);
await assert.rejects(drawRound(client, roundId, 6, staffId), /Platzzahl/);
await drawRound(client, roundId, 2, staffId);
await assert.rejects(drawRound(client, roundId, 2, staffId), /erneut ausgelost/);
const apps = await client.query<{ id: string; status: string; child_id: string | null }>("SELECT id, status, child_id FROM applications WHERE round_id = $1", [roundId]);
const winners = apps.rows.filter((app) => app.status === "selected");
const losers = apps.rows.filter((app) => app.status === "waitlisted");
assert.equal(winners.length, 2);
assert.equal(losers.length, 3);
assert.ok(apps.rows.every((app) => app.child_id === null));
await assert.rejects(acceptApplication(client, losers[0].id, staffId), /Nur ausgewählte/);
await acceptApplication(client, winners[0].id, staffId);
await assert.rejects(acceptApplication(client, winners[0].id, staffId), /Nur ausgewählte/);
const accepted = await client.query<{ child_id: string; accepted_by: string }>("SELECT child_id, accepted_by FROM applications WHERE id = $1", [winners[0].id]);
const childId = accepted.rows[0].child_id;
assert.equal(accepted.rows[0].accepted_by, staffId);
assert.ok((await getChildren("2026-11-03", client, childId))[0]);
assert.equal((await client.query("SELECT id FROM pickup_contacts WHERE child_id = $1", [childId])).rowCount, 0);
assert.equal((await client.query("SELECT id FROM audit_log WHERE child_id = $1 AND actor_id = $2", [childId, staffId])).rowCount, 1);
const stored = await client.query<{ code_hash: string; places: number; drawn_by: string }>("SELECT code_hash, places, drawn_by FROM admission_rounds WHERE id = $1", [roundId]);
assert.notEqual(stored.rows[0].code_hash, code);
assert.equal(stored.rows[0].places, 2);
assert.equal(stored.rows[0].drawn_by, staffId);
const expiredCode = newInvitation();
await client.query(`INSERT INTO admission_rounds(name, code_hash, expires_on, created_by)
VALUES ('Abgelaufen', $1, '2000-01-01', $2)`, [invitationHash(expiredCode), staffId]);
await assert.rejects(submitApplication(client, { ...input, code: expiredCode }), /abgelaufen/);
} finally { await client.query("ROLLBACK"); client.release(); }
});
test("Zwei parallele Auslosungen können dieselbe Runde nicht gleichzeitig verändern", async () => {
const first = await db.connect();
const second = await db.connect();
let roundId: string | undefined;
try {
const staff = await db.query<{ id: string }>("SELECT id FROM staff LIMIT 1");
const staffId = staff.rows[0].id;
const code = newInvitation();
const round = await db.query<{ id: string }>(`INSERT INTO admission_rounds(name, code_hash, expires_on, created_by)
VALUES ('Temporärer Paralleltest', $1, '2100-12-31', $2) RETURNING id`, [invitationHash(code), staffId]);
roundId = round.rows[0].id;
await db.query(`INSERT INTO applications(round_id, first_name, last_name, class_name, parent_name, email)
SELECT $1, 'Parallel ' || n, 'Test', 'TEST', 'Testkontakt', 'parallel@example.invalid' FROM generate_series(1,3) n`, [roundId]);
await db.query("UPDATE admission_rounds SET status = 'closed' WHERE id = $1", [roundId]);
await first.query("BEGIN");
await drawRound(first, roundId, 1, staffId);
await second.query("BEGIN");
await second.query("SET LOCAL lock_timeout = '150ms'");
await assert.rejects(drawRound(second, roundId, 2, staffId), /lock timeout/);
await second.query("ROLLBACK");
await first.query("COMMIT");
await second.query("BEGIN");
await assert.rejects(drawRound(second, roundId, 2, staffId), /erneut ausgelost/);
await second.query("ROLLBACK");
const final = await db.query<{ selected: number }>("SELECT count(*)::int AS selected FROM applications WHERE round_id = $1 AND status = 'selected'", [roundId]);
assert.equal(final.rows[0].selected, 1);
} finally {
await first.query("ROLLBACK");
await second.query("ROLLBACK");
first.release(); second.release();
if (roundId) {
await db.query("DELETE FROM applications WHERE round_id = $1", [roundId]);
await db.query("DELETE FROM admission_rounds WHERE id = $1", [roundId]);
}
}
});
+138
View File
@@ -0,0 +1,138 @@
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<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);
});