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>
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();
|
|
});
|
|
});
|