// SPDX-FileCopyrightText: 2026 Paul Schoeneck // // SPDX-License-Identifier: AGPL-3.0-only AND LicenseRef-KIBU-Additional-Terms 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]); } } });