Files
FireAdministration/tests/unit/atemschutz-datenbank.test.ts
T
Florian WildeandClaude Opus 5 30faa6cb02
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s
Version 1.0.0 — Freigabe fuer den Echtbetrieb
Kein neues Modul. Zwoelf Phasen sind fertig, und die Wehr kann ab jetzt
echte Daten hineingeben, ohne befuerchten zu muessen, sie beim naechsten
Update zu verlieren.

Neu dazu, weil beides zum Betrieb gehoert:

update.sh aktualisiert auf einem Linux-Server in einem Befehl. Der Ablauf
darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage zu
hinterlassen: erst nachsehen ob es etwas Neues gibt, dann sichern, dann
holen und bauen (schlaegt das fehl, laeuft die alte Fassung unveraendert
weiter), dann umschalten, dann pruefen ob die Anwendung antwortet. Tut sie
es nicht, stehen die Protokollzeilen da und die Befehle fuer den Rueckweg.

docs/KURZANLEITUNG.md ist der Einstieg fuer die Wehr - nach Rollen
sortiert, aufgabenorientiert statt nach Modulen, und bei jedem heiklen
Punkt steht das Warum dabei. Die vollstaendige Anleitung hat 63 Kapitel;
die liest niemand, bevor er das erste Geraet pruefen will.

Die Roadmap haelt den Stand JEDER Freigabevoraussetzung fest, auch der
offenen: Datenuebernahme entfaellt (die Wehr pflegt von Hand ein),
Sicherungskonzept erledigt, Loeschkonzept technisch vollstaendig aber
nicht abgenommen, Schulung steht aus, Atemschutzhinweis bewusst stehen
gelassen. Eine Freigabe, die abhakt was nicht geprueft wurde, ist keine.

Nebenbei die Ursache der Testflackerei gefunden, die sich durch mehrere
Phasen zog: zwei Datenbanktests schalteten beim Aufraeumen ihre Trigger
mit ALTER TABLE ... DISABLE TRIGGER ab - eine Schemaaenderung, die fuer
die ganze Datenbank gilt. Vitest faehrt die Dateien parallel, also pruefte
eine Datei gerade dass ein Trigger greift, waehrend eine andere ihn
abgeraeumt hatte. Jetzt sitzungslokal ueber session_replication_role.

Erstmals in diesem Projekt: fuenf Unit-Testlaeufe hintereinander 601 gruen
und ein E2E-Lauf mit 268 gruen ohne Fehlschlag und ohne Flackern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:09:26 +02:00

370 lines
12 KiB
TypeScript

import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Was die Datenbank beim Atemschutz durchsetzt.
*
* Dieses Modul ist das sicherheitsrelevanteste im Projekt, und im Schadensfall
* ist das Protokoll das Papier, nach dem gefragt wird. Deshalb liegen die
* Regeln nicht (nur) im Anwendungscode:
*
* 1. Druckmessungen und Ereignisse lassen sich nicht ändern und nicht
* löschen — weder über die Anwendung noch über den Systemzugang.
* 2. Unmögliche Werte werden abgewiesen: negativer Druck, ein Rückzug vor
* dem Einstieg, ein Ende vor dem Beginn.
* 3. Genau ein Truppführer je Trupp.
* 4. Mandantentrennung der sieben neuen Tabellen.
*
* Ohne erreichbare Datenbank überspringen sich die Tests selbst.
*/
const APP_URL = process.env.DATABASE_URL ?? "";
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
const SLUG = "test-atemschutz-wehr";
let erreichbar = false;
let organisationId = "";
let personA = "";
let personB = "";
let ueberwachungId = "";
beforeAll(async () => {
try {
await system.$queryRaw`SELECT 1`;
erreichbar = true;
} catch {
return;
}
const organisation = await system.organisation.upsert({
where: { slug: SLUG },
update: {},
create: { slug: SLUG, name: "Atemschutz-Testwehr", kurzname: "ATW" },
});
organisationId = organisation.id;
const anlegen = async (nummer: string, vorname: string, nachname: string) => {
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } },
update: {},
create: { organisationId, mitgliedsnummer: nummer, vorname, nachname },
});
return person.id;
};
personA = await anlegen("A001", "Anton", "Atemschutz");
personB = await anlegen("A002", "Berta", "Atemschutz");
const ueberwachung = await system.atemschutzeinsatz.create({
data: {
organisationId,
bezeichnung: "Testüberwachung",
ueberwacherId: personA,
beginnUm: new Date("2026-06-01T10:00:00.000Z"),
},
});
ueberwachungId = ueberwachung.id;
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
// Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf
// den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz
// abgeschaltet; im Betrieb passiert das nie.
// Trigger fuer das Aufraeumen stilllegen — sitzungslokal.
//
// NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine
// Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien
// parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo
// eine Datei, dass genau dieser Trigger greift — und sah keinen.
// `session_replication_role` wirkt nur in dieser Transaktion.
await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'");
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
/** Legt einen Trupp mit zwei Personen an. */
async function truppAnlegen(funkrufname: string, felder: Record<string, unknown> = {}) {
return system.atemschutztrupp.create({
data: {
organisationId,
atemschutzeinsatzId: ueberwachungId,
funkrufname,
angelegtUm: new Date("2026-06-01T10:05:00.000Z"),
mitglieder: {
create: [
{ organisationId, personId: personA, truppfuehrer: true },
{ organisationId, personId: personB, truppfuehrer: false },
],
},
...felder,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Das Protokoll ist unveränderlich", () => {
it("nimmt eine Druckmessung an", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Messtrupp");
const messung = await system.druckmessung.create({
data: {
organisationId,
truppId: trupp.id,
personId: personA,
druck: 300,
art: "EINGANG",
},
});
expect(messung.druck).toBe(300);
});
it("weist jede Änderung einer Druckmessung ab", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Änderungstrupp");
const messung = await system.druckmessung.create({
data: { organisationId, truppId: trupp.id, personId: personA, druck: 280, art: "ZWISCHEN" },
});
// Ein Tippfehler wird durch eine neue Messung richtiggestellt — nicht
// durch Überschreiben. Sonst ließe sich jede Aufzeichnung glätten.
await expect(
system.druckmessung.update({ where: { id: messung.id }, data: { druck: 180 } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("weist das Löschen einer Druckmessung ab", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Löschtrupp");
const messung = await system.druckmessung.create({
data: { organisationId, truppId: trupp.id, personId: personA, druck: 260, art: "ZWISCHEN" },
});
await expect(
system.druckmessung.delete({ where: { id: messung.id } }),
).rejects.toThrow(/unveraenderlich/i);
});
it("weist jede Änderung eines Ereignisses ab", async ({ skip }) => {
if (!erreichbar) return skip();
const ereignis = await system.atemschutzEreignis.create({
data: {
organisationId,
atemschutzeinsatzId: ueberwachungId,
art: "MAYDAY",
text: "MAYDAY — Angriffstrupp",
},
});
await expect(
system.atemschutzEreignis.update({
where: { id: ereignis.id },
data: { text: "war doch nichts" },
}),
).rejects.toThrow(/unveraenderlich/i);
});
});
describe.runIf(process.env.DATABASE_URL)("Werte, die es nicht geben darf", () => {
it("weist einen negativen Druck ab", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Minustrupp");
await expect(
system.druckmessung.create({
data: { organisationId, truppId: trupp.id, personId: personA, druck: -5, art: "ZWISCHEN" },
}),
).rejects.toThrow(/druckmessung_druck_moeglich/i);
});
it("weist einen Druck jenseits jeder Flasche ab", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Zahlendrehertrupp");
// 3000 statt 300 — der klassische Tippfehler unter Zeitdruck.
await expect(
system.druckmessung.create({
data: { organisationId, truppId: trupp.id, personId: personA, druck: 3000, art: "EINGANG" },
}),
).rejects.toThrow(/druckmessung_druck_moeglich/i);
});
it("weist einen Rückzug vor dem Einstieg ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
truppAnlegen("Verdrehttrupp", {
einstiegUm: new Date("2026-06-01T10:30:00.000Z"),
rueckzugUm: new Date("2026-06-01T10:20:00.000Z"),
}),
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
});
it("weist einen Rückzug ohne Einstieg ab", async ({ skip }) => {
if (!erreichbar) return skip();
// Ein Trupp, der zurückgeht, ohne je drin gewesen zu sein, ist kein
// Trupp, sondern ein Erfassungsfehler.
await expect(
truppAnlegen("Geistertrupp", { rueckzugUm: new Date("2026-06-01T10:20:00.000Z") }),
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
});
it("weist eine Aufhebung ohne Notfall ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
truppAnlegen("Scheintrupp", {
maydayAufgehobenUm: new Date("2026-06-01T10:40:00.000Z"),
}),
).rejects.toThrow(/trupp_mayday_aufhebung_danach/i);
});
it("weist ein Ende vor dem Beginn ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.atemschutzeinsatz.create({
data: {
organisationId,
bezeichnung: "Rückwärtsüberwachung",
ueberwacherId: personA,
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
endeUm: new Date("2026-06-01T11:00:00.000Z"),
},
}),
).rejects.toThrow(/atemschutz_ende_nach_beginn/i);
});
it("weist ein unsinniges Abfrageintervall ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.atemschutzeinsatz.create({
data: {
organisationId,
bezeichnung: "Sekundentakt",
ueberwacherId: personA,
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
abfrageIntervall: 5,
},
}),
).rejects.toThrow(/atemschutz_intervall_sinnvoll/i);
});
it("weist eine G26 ab, die vor der Untersuchung endet", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.g26Untersuchung.create({
data: {
organisationId,
personId: personA,
untersuchtAm: new Date("2026-06-01T00:00:00.000Z"),
gueltigBis: new Date("2025-06-01T00:00:00.000Z"),
},
}),
).rejects.toThrow(/g26_gueltig_nach_untersuchung/i);
});
});
describe.runIf(process.env.DATABASE_URL)("Genau ein Truppführer", () => {
it("lässt einen zweiten Truppführer nicht zu", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Doppelfuehrertrupp");
// Im Protokoll muss stehen, wer verantwortlich war — genau einer. Der
// Teilindex truppmitglied_ein_fuehrer setzt das durch; Prisma meldet ihn
// als verletzte Eindeutigkeit auf truppId.
await expect(
system.truppmitglied.update({
where: { truppId_personId: { truppId: trupp.id, personId: personB } },
data: { truppfuehrer: true },
}),
).rejects.toThrow(/unique constraint failed.*truppId/i);
});
it("lässt dieselbe Person nicht zweimal im Trupp zu", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Doppeltrupp");
await expect(
system.truppmitglied.create({
data: { organisationId, truppId: trupp.id, personId: personA },
}),
).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung des Atemschutzes", () => {
it("zeigt ohne gesetzte Wehr nichts", async ({ skip }) => {
if (!erreichbar) return skip();
const gefunden = await app.$transaction(async (tx) => ({
ueberwachungen: await tx.atemschutzeinsatz.findMany(),
trupps: await tx.atemschutztrupp.findMany(),
mitglieder: await tx.truppmitglied.findMany(),
messungen: await tx.druckmessung.findMany(),
ereignisse: await tx.atemschutzEreignis.findMany(),
untersuchungen: await tx.g26Untersuchung.findMany(),
uebungen: await tx.belastungsuebung.findMany(),
}));
for (const [tabelle, zeilen] of Object.entries(gefunden)) {
expect(zeilen, `${tabelle} darf ohne Mandantenkontext nichts zeigen`).toHaveLength(0);
}
});
it("zeigt im Kontext der Wehr nur deren eigene Überwachungen", async ({ skip }) => {
if (!erreichbar) return skip();
const ueberwachungen = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.atemschutzeinsatz.findMany();
});
expect(ueberwachungen.length).toBeGreaterThan(0);
expect(ueberwachungen.every((eintrag) => eintrag.organisationId === organisationId)).toBe(true);
});
it("lässt den Anwendungszugang eine Messung anlegen, aber nicht ändern", async ({ skip }) => {
if (!erreichbar) return skip();
const trupp = await truppAnlegen("Rechtetrupp");
const messungId = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
const messung = await tx.druckmessung.create({
data: { organisationId, truppId: trupp.id, personId: personA, druck: 210, art: "ZWISCHEN" },
});
return messung.id;
});
expect(messungId).toBeTruthy();
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.$executeRaw`UPDATE "druckmessung" SET "druck" = 999 WHERE "id" = ${messungId}::uuid`;
}),
).rejects.toThrow();
});
});