Files
FireAdministration/tests/unit/atemschutz-datenbank.test.ts
T
florian.wilde 3b20abcb23
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
Version 1.0.0 — Freigabe für den Echtbetrieb (#17)
2026-09-06 20:08:16 +00: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();
});
});