370 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|