Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
275 lines
17 KiB
TypeScript
275 lines
17 KiB
TypeScript
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]);
|
|
}
|
|
}
|
|
});
|