Atemschutzueberwachung nach FwDV 7 ergaenzen

Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 23:19:05 +02:00
co-authored by Claude Opus 5
parent b2b31ed00a
commit 4e2daaafd4
107 changed files with 7214 additions and 40 deletions
+314
View File
@@ -0,0 +1,314 @@
import { describe, expect, it } from "vitest";
import {
abfrageUeberfaellig,
alsUhr,
ampel,
einsatzdauer,
naechsteAbfrage,
niedrigsterDruck,
restzeitMinuten,
rueckzugsdruck,
statuswechselErlaubt,
verbrauchProMinute,
} from "@/lib/atemschutz/berechnung";
/**
* Die Rechenregeln der Atemschutzüberwachung.
*
* Das sicherheitsrelevanteste Stück Code im Projekt — deshalb wird hier nicht
* geprüft, ob eine Zahl herauskommt, sondern ob die richtige herauskommt, und
* zwar auch in den Fällen, die im Einsatz wirklich vorkommen: eine fehlende
* Meldung, ein Trupp mit ungleichem Verbrauch, ein Wert, der steigt statt zu
* fallen.
*/
const T0 = new Date("2026-06-15T10:00:00.000Z");
/** Zeitpunkt X Minuten nach T0. */
function nach(minuten: number): Date {
return new Date(T0.getTime() + minuten * 60_000);
}
describe("Rückzugsdruck", () => {
it("rechnet den doppelten Anmarschverbrauch, sobald das Ziel gemeldet ist", () => {
// 300 rein, 240 am Ziel: 60 bar für den Weg hin, also 60 für zurück.
// 120 + 55 Warndruck + 10 Zuschlag = 185.
const ergebnis = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: 240,
warndruck: 55,
sicherheitszuschlag: 10,
});
expect(ergebnis).toEqual({ druck: 185, regel: "anmarschverbrauch" });
});
it("greift ohne Zielmeldung auf die Zwei-Drittel-Regel zurück", () => {
const ergebnis = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: null,
warndruck: 55,
sicherheitszuschlag: 10,
});
// 300 × 2/3 = 200, plus Zuschlag.
expect(ergebnis).toEqual({ druck: 210, regel: "zweidrittel" });
});
it("ist ohne Zielmeldung vorsichtiger als mit", () => {
const ohne = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: null,
warndruck: 55,
sicherheitszuschlag: 10,
});
const mit = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: 240,
warndruck: 55,
sicherheitszuschlag: 10,
});
// Genau darum ist der Rückfall vertretbar: er liegt höher, nicht tiefer.
expect(ohne.druck).toBeGreaterThan(mit.druck);
});
it("kommt bei langem Anmarsch nie über den Eingangsdruck hinaus", () => {
// Halbe Flasche für den Hinweg — rechnerisch wären 355 bar nötig. Das
// gibt es nicht; der Trupp hätte gar nicht erst so weit gehen dürfen.
const ergebnis = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: 150,
warndruck: 55,
sicherheitszuschlag: 10,
});
expect(ergebnis.druck).toBe(300);
});
it("ignoriert einen Zieldruck über dem Eingangsdruck", () => {
// Ein Zahlendreher beim Ablesen darf nicht dazu führen, dass der
// Rückzugsdruck sinkt.
const ergebnis = rueckzugsdruck({
eingangsdruck: 200,
zieldruck: 280,
warndruck: 55,
sicherheitszuschlag: 10,
});
expect(ergebnis.regel).toBe("zweidrittel");
});
it("nimmt den Sicherheitszuschlag des Kommandanten mit", () => {
const streng = rueckzugsdruck({
eingangsdruck: 300,
zieldruck: 240,
warndruck: 55,
sicherheitszuschlag: 30,
});
expect(streng.druck).toBe(205);
});
});
describe("Niedrigster Druck im Trupp", () => {
it("nimmt je Person die letzte Messung und davon die kleinste", () => {
const ergebnis = niedrigsterDruck([
{ personId: "a", druck: 300, zeitpunkt: nach(0) },
{ personId: "b", druck: 300, zeitpunkt: nach(0) },
{ personId: "a", druck: 220, zeitpunkt: nach(10) },
{ personId: "b", druck: 190, zeitpunkt: nach(10) },
]);
expect(ergebnis?.personId).toBe("b");
expect(ergebnis?.druck).toBe(190);
});
it("lässt sich von einer alten Messung nicht täuschen", () => {
// Person b hatte einmal 100 bar gemeldet — das war aber vorher, und
// inzwischen steht ein neuer Wert. Der zählt.
const ergebnis = niedrigsterDruck([
{ personId: "a", druck: 210, zeitpunkt: nach(10) },
{ personId: "b", druck: 100, zeitpunkt: nach(5) },
{ personId: "b", druck: 240, zeitpunkt: nach(10) },
]);
expect(ergebnis?.personId).toBe("a");
expect(ergebnis?.druck).toBe(210);
});
it("gibt null zurück, solange nichts gemeldet ist", () => {
expect(niedrigsterDruck([])).toBeNull();
});
});
describe("Ampel", () => {
it("steht auf grün, solange Luft ist", () => {
expect(ampel({ aktuellerDruck: 250, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gruen");
});
it("warnt, bevor der Rückzugsdruck erreicht ist", () => {
// Zehn Prozent über der Grenze: der Trupp soll losgehen, nicht warten.
expect(ampel({ aktuellerDruck: 200, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gelb");
});
it("steht auf rot, sobald die Grenze erreicht ist", () => {
expect(ampel({ aktuellerDruck: 185, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("rot");
expect(ampel({ aktuellerDruck: 120, rueckzugsdruck: 185, status: "RUECKWEG" })).toBe("rot");
});
it("lässt einen Trupp, der draußen ist, in Ruhe", () => {
expect(ampel({ aktuellerDruck: 40, rueckzugsdruck: 185, status: "DRAUSSEN" })).toBe("gruen");
});
it("bewertet nichts, solange nichts gemeldet ist", () => {
expect(ampel({ aktuellerDruck: null, rueckzugsdruck: 185, status: "IM_EINSATZ" })).toBe("gruen");
});
});
describe("Einsatzdauer", () => {
it("rechnet aus zwei Zeitstempeln, nicht aus einem Zähler", () => {
expect(einsatzdauer({ einstiegUm: T0, drausenUm: null }, nach(12))).toBe(720);
});
it("bleibt nach dem Herauskommen stehen", () => {
expect(einsatzdauer({ einstiegUm: T0, drausenUm: nach(18) }, nach(90))).toBe(1080);
});
it("zählt erst ab dem Einstieg", () => {
expect(einsatzdauer({ einstiegUm: null, drausenUm: null }, nach(30))).toBeNull();
});
it("überlebt eine Uhr, die zurückspringt", () => {
// Sommerzeit, eine korrigierte Systemuhr: die Dauer wird nie negativ.
expect(einsatzdauer({ einstiegUm: nach(10), drausenUm: null }, T0)).toBe(0);
});
});
describe("Druckabfrage", () => {
it("wird ab der letzten Meldung gerechnet, nicht ab dem Einstieg", () => {
const faellig = naechsteAbfrage({
einstiegUm: T0,
letzteMessungUm: nach(7),
intervallSekunden: 600,
});
expect(faellig).toEqual(nach(17));
});
it("ist überfällig, sobald das Intervall abgelaufen ist", () => {
const angaben = {
einstiegUm: T0,
letzteMessungUm: T0,
intervallSekunden: 600,
status: "IM_EINSATZ" as const,
};
expect(abfrageUeberfaellig({ ...angaben, jetzt: nach(9) })).toBe(false);
expect(abfrageUeberfaellig({ ...angaben, jetzt: nach(10) })).toBe(true);
});
it("erinnert nicht an Trupps, die noch nicht oder nicht mehr drin sind", () => {
const angaben = {
einstiegUm: T0,
letzteMessungUm: T0,
intervallSekunden: 600,
jetzt: nach(60),
};
expect(abfrageUeberfaellig({ ...angaben, status: "BEREIT" })).toBe(false);
expect(abfrageUeberfaellig({ ...angaben, status: "DRAUSSEN" })).toBe(false);
expect(abfrageUeberfaellig({ ...angaben, status: "RUECKWEG" })).toBe(true);
});
});
describe("Verbrauch und Restzeit", () => {
it("rechnet den Verbrauch je Minute aus erster und letzter Messung", () => {
const rate = verbrauchProMinute([
{ druck: 300, zeitpunkt: T0 },
{ druck: 240, zeitpunkt: nach(10) },
]);
expect(rate).toBe(6);
});
it("rechnet nichts aus einer einzelnen Messung", () => {
expect(verbrauchProMinute([{ druck: 300, zeitpunkt: T0 }])).toBeNull();
});
it("rechnet nichts aus zwei Messungen im Sekundenabstand", () => {
const rate = verbrauchProMinute([
{ druck: 300, zeitpunkt: T0 },
{ druck: 295, zeitpunkt: new Date(T0.getTime() + 20_000) },
]);
// 15 bar je Minute wären hochgerechneter Unsinn.
expect(rate).toBeNull();
});
it("rechnet nichts, wenn der Druck gestiegen ist", () => {
const rate = verbrauchProMinute([
{ druck: 200, zeitpunkt: T0 },
{ druck: 260, zeitpunkt: nach(5) },
]);
// Flasche gewechselt oder falsch abgelesen — beides ist keine Grundlage.
expect(rate).toBeNull();
});
it("schätzt die Restzeit bis zum Rückzugsdruck", () => {
expect(
restzeitMinuten({ aktuellerDruck: 245, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
).toBe(10);
});
it("meldet null Minuten, wenn die Grenze erreicht ist", () => {
expect(
restzeitMinuten({ aktuellerDruck: 185, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
).toBe(0);
});
it("gibt lieber keine Zahl aus als eine erfundene", () => {
expect(
restzeitMinuten({ aktuellerDruck: 245, rueckzugsdruck: 185, verbrauchProMinute: null }),
).toBeNull();
expect(
restzeitMinuten({ aktuellerDruck: null, rueckzugsdruck: 185, verbrauchProMinute: 6 }),
).toBeNull();
});
});
describe("Statusfolge", () => {
it("lässt den üblichen Weg zu", () => {
expect(statuswechselErlaubt("BEREIT", "IM_EINSATZ")).toBe(true);
expect(statuswechselErlaubt("IM_EINSATZ", "RUECKWEG")).toBe(true);
expect(statuswechselErlaubt("RUECKWEG", "DRAUSSEN")).toBe(true);
});
it("lässt einen Trupp auch ohne gemeldeten Rückweg herauskommen", () => {
// Kommt vor: kurzer Auftrag, der Trupp steht plötzlich wieder da.
expect(statuswechselErlaubt("IM_EINSATZ", "DRAUSSEN")).toBe(true);
});
it("lässt niemanden zurückspringen", () => {
expect(statuswechselErlaubt("DRAUSSEN", "IM_EINSATZ")).toBe(false);
expect(statuswechselErlaubt("RUECKWEG", "IM_EINSATZ")).toBe(false);
expect(statuswechselErlaubt("IM_EINSATZ", "BEREIT")).toBe(false);
});
});
describe("Uhrzeitanzeige", () => {
it("zeigt Minuten und Sekunden", () => {
expect(alsUhr(0)).toBe("00:00");
expect(alsUhr(65)).toBe("01:05");
expect(alsUhr(599)).toBe("09:59");
});
it("nimmt die Stunde dazu, sobald es länger dauert", () => {
expect(alsUhr(3661)).toBe("1:01:01");
});
it("zeigt einen Strich, solange nichts läuft", () => {
expect(alsUhr(null)).toBe("—");
});
});
+365
View File
@@ -0,0 +1,365 @@
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.
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" DISABLE TRIGGER USER');
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" DISABLE TRIGGER USER');
await tx.organisation.deleteMany({ where: { slug: SLUG } });
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" ENABLE TRIGGER USER');
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" ENABLE TRIGGER USER');
});
}
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();
});
});
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { g26FristVorschlag, tauglichkeit } from "@/lib/atemschutz/tauglichkeit";
/**
* Wer darf unter Atemschutz?
*
* Die Frage entscheidet der Einheitsführer, nicht diese Funktion. Was sie
* leisten muss: keinen der drei Nachweise unterschlagen und im Klartext sagen,
* welcher fehlt. Eine Ampel, die rot ist, ohne zu sagen warum, hilft am
* Fahrzeug niemandem.
*/
const HEUTE = new Date("2026-06-15T08:00:00.000Z");
function datum(text: string): Date {
return new Date(`${text}T00:00:00.000Z`);
}
/** Der Regelfall: alles vorhanden und gültig. */
function vollstaendig() {
return {
untersuchungen: [{ gueltigBis: datum("2028-03-01"), ergebnis: "TAUGLICH" as const }],
uebungen: [
{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" as const },
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" as const },
],
einsaetzeUnterPa: [] as Date[],
istAtemschutzgeraetetraeger: true,
jetzt: HEUTE,
};
}
describe("Tauglichkeit nach FwDV 7", () => {
it("erklärt jemanden mit allen drei Nachweisen für einsetzbar", () => {
const ergebnis = tauglichkeit(vollstaendig());
expect(ergebnis.stufe).toBe("tauglich");
expect(ergebnis.gruende).toEqual([]);
expect(ergebnis.g26GueltigBis).toEqual(datum("2028-03-01"));
});
it("nennt eine fehlende G26 beim Namen", () => {
const ergebnis = tauglichkeit({ ...vollstaendig(), untersuchungen: [] });
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende).toContain("Keine G26.3-Untersuchung hinterlegt");
});
it("erkennt eine abgelaufene G26", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [{ gueltigBis: datum("2026-01-31"), ergebnis: "TAUGLICH" }],
});
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende).toContain("G26.3 ist abgelaufen");
});
it("warnt drei Monate vor Ablauf, ohne die Person schon auszuschließen", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [{ gueltigBis: datum("2026-08-01"), ergebnis: "TAUGLICH" }],
});
expect(ergebnis.stufe).toBe("laeuft_ab");
expect(ergebnis.gruende.join(" ")).toMatch(/läuft in \d+ Tagen ab/);
});
it("nimmt die neueste Untersuchung, nicht die erstbeste", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [
{ gueltigBis: datum("2024-05-01"), ergebnis: "TAUGLICH" },
{ gueltigBis: datum("2029-05-01"), ergebnis: "TAUGLICH" },
{ gueltigBis: datum("2026-05-01"), ergebnis: "TAUGLICH" },
],
});
expect(ergebnis.stufe).toBe("tauglich");
expect(ergebnis.g26GueltigBis).toEqual(datum("2029-05-01"));
});
it("lässt eine befristete Tauglichkeit gelten", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [{ gueltigBis: datum("2027-01-01"), ergebnis: "BEFRISTET_TAUGLICH" }],
});
expect(ergebnis.stufe).toBe("tauglich");
});
it("zählt eine Nachuntersuchung nicht als Nachweis", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [{ gueltigBis: datum("2027-01-01"), ergebnis: "NACHUNTERSUCHUNG" }],
});
expect(ergebnis.stufe).toBe("nicht_tauglich");
});
it("sagt es deutlich, wenn der Arzt „nicht tauglich“ geschrieben hat", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
untersuchungen: [{ gueltigBis: datum("2028-01-01"), ergebnis: "NICHT_TAUGLICH" }],
});
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende.join(" ")).toContain("nicht tauglich");
});
it("verlangt die Belastungsübung im laufenden Jahr", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
uebungen: [
// Letztes Jahr reicht nicht.
{ datum: datum("2025-11-02"), art: "UEBUNGSSTRECKE" },
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" },
],
});
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende).toContain("Belastungsübung in diesem Jahr fehlt");
});
it("lässt einen echten Einsatz unter Atemschutz für die Einsatzübung gelten", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
uebungen: [{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" }],
einsaetzeUnterPa: [datum("2026-05-03")],
});
expect(ergebnis.stufe).toBe("tauglich");
expect(ergebnis.einsatzuebungAm).toEqual(datum("2026-05-03"));
});
it("lässt einen Einsatz aus dem Vorjahr nicht gelten", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
uebungen: [{ datum: datum("2026-02-10"), art: "UEBUNGSSTRECKE" }],
einsaetzeUnterPa: [datum("2025-12-30")],
});
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende).toContain("Einsatzübung unter Atemschutz in diesem Jahr fehlt");
});
it("zählt eine Unterweisung nicht als Belastungsübung", () => {
const ergebnis = tauglichkeit({
...vollstaendig(),
uebungen: [
{ datum: datum("2026-03-01"), art: "UNTERWEISUNG" },
{ datum: datum("2026-04-20"), art: "EINSATZUEBUNG" },
],
});
expect(ergebnis.gruende).toContain("Belastungsübung in diesem Jahr fehlt");
});
it("verlangt die Qualifikation als Geräteträger", () => {
const ergebnis = tauglichkeit({ ...vollstaendig(), istAtemschutzgeraetetraeger: false });
expect(ergebnis.stufe).toBe("nicht_tauglich");
expect(ergebnis.gruende).toContain("Keine Qualifikation als Atemschutzgeräteträger");
});
it("nennt alle fehlenden Nachweise, nicht nur den ersten", () => {
const ergebnis = tauglichkeit({
untersuchungen: [],
uebungen: [],
einsaetzeUnterPa: [],
istAtemschutzgeraetetraeger: false,
jetzt: HEUTE,
});
// Wer nur „nicht tauglich" liest, weiß nicht, was zu tun ist.
expect(ergebnis.gruende).toHaveLength(4);
});
});
describe("Fristvorschlag für die G26.3", () => {
it("schlägt drei Jahre vor, solange jemand unter fünfzig ist", () => {
const frist = g26FristVorschlag(datum("1990-04-01"), datum("2026-06-15"));
expect(frist).toEqual(datum("2029-06-15"));
});
it("schlägt ein Jahr vor, sobald jemand fünfzig ist", () => {
const frist = g26FristVorschlag(datum("1972-01-10"), datum("2026-06-15"));
expect(frist).toEqual(datum("2027-06-15"));
});
it("nimmt ohne Geburtsdatum den längeren Zeitraum — überschreibbar bleibt er ohnehin", () => {
const frist = g26FristVorschlag(null, datum("2026-06-15"));
expect(frist).toEqual(datum("2029-06-15"));
});
});