Files
KIBU/tests/database.test.ts
T
Paul Schoeneck 75af38a4b6 Add licensing information and update README for KIBU
- Introduced additional terms under Section 7 of the GNU AGPL v3 for KIBU, including attribution, marking of modified versions, and trademark usage.
- Added MIT License file for clarity on licensing options.
- Updated README.md to reflect the new licensing terms, including detailed usage rights and obligations.
- Created REUSE.toml for SPDX compliance and license annotations.
- Added THIRD_PARTY_NOTICES.md to clarify third-party dependencies and their licenses.
- Updated various source files and scripts to include SPDX headers for copyright and licensing.
- Enhanced footer in layout to display licensing information and project link.
- Implemented a new license page to present the AGPL and additional terms clearly.
2026-11-03 08:27:12 +01:00

279 lines
17 KiB
TypeScript

// 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]);
}
}
});