Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP, ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite. Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr verlaesst, bekommt nur die Kurzfassung ohne Namen und Details. Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp (Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) -- beides im README begruendet. 56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c779266450
commit
0b8874da26
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EreignisTyp, Kanal } from "@prisma/client";
|
||||
import {
|
||||
KANAL_EXTERN,
|
||||
PLATZHALTER,
|
||||
STANDARD,
|
||||
einsetzen,
|
||||
nachrichtBauen,
|
||||
unbekanntePlatzhalter,
|
||||
} from "@/lib/benachrichtigung/vorlagen";
|
||||
|
||||
/**
|
||||
* Die Textbausteine.
|
||||
*
|
||||
* Zwei Dinge werden hier scharf geprüft, weil beide Datenschutzfragen sind
|
||||
* und nicht nur Formatierung:
|
||||
*
|
||||
* 1. Über externe Kanäle geht die Kurzfassung — ohne Namen, ohne Details.
|
||||
* 2. Ein unbekannter Platzhalter bleibt sichtbar stehen, statt still zu
|
||||
* verschwinden. Eine Lücke, die niemand bemerkt, ist schlimmer als ein
|
||||
* seltsam aussehender Text, den jemand korrigiert.
|
||||
*/
|
||||
|
||||
describe("einsetzen", () => {
|
||||
it("ersetzt bekannte Platzhalter", () => {
|
||||
expect(einsetzen("{anzahl} Prüfungen bei der {wehr}", { anzahl: 3, wehr: "FF Musterhausen" })).toBe(
|
||||
"3 Prüfungen bei der FF Musterhausen",
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt einen unbekannten Platzhalter stehen", () => {
|
||||
expect(einsetzen("Hallo {tippfehler}", { name: "Max" })).toBe("Hallo {tippfehler}");
|
||||
});
|
||||
|
||||
it("macht aus null einen Gedankenstrich, nicht aus leerer Luft", () => {
|
||||
// Ein leerer Ort soll als "—" sichtbar sein: "Ort: " ohne Angabe sieht
|
||||
// aus wie ein Fehler in der Software, "Ort: —" wie eine fehlende Angabe.
|
||||
expect(einsetzen("Ort: {ort}", { ort: null })).toBe("Ort: —");
|
||||
});
|
||||
|
||||
it("kommt mit Umlauten im Platzhalternamen zurecht", () => {
|
||||
expect(einsetzen("{faelligAm}", { faelligAm: "01.10.2026" })).toBe("01.10.2026");
|
||||
});
|
||||
|
||||
it("ersetzt denselben Platzhalter mehrfach", () => {
|
||||
expect(einsetzen("{a} und nochmal {a}", { a: "x" })).toBe("x und nochmal x");
|
||||
});
|
||||
|
||||
it("lässt Text ohne Platzhalter unverändert", () => {
|
||||
expect(einsetzen("Nichts zu ersetzen.", {})).toBe("Nichts zu ersetzen.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nachrichtBauen", () => {
|
||||
it("nimmt für interne Kanäle den ausführlichen Text", () => {
|
||||
const nachricht = nachrichtBauen({
|
||||
ereignis: EreignisTyp.PRUEFUNG_FAELLIG,
|
||||
kanal: Kanal.MAIL,
|
||||
werte: {
|
||||
wehr: "FF Musterhausen",
|
||||
anzahl: 3,
|
||||
geraet: "Pressluftatmer",
|
||||
inventarnummer: "INV-0042",
|
||||
faelligAm: "01.10.2026",
|
||||
tage: 7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nachricht.text).toContain("INV-0042");
|
||||
expect(nachricht.text).toContain("FF Musterhausen");
|
||||
});
|
||||
|
||||
/**
|
||||
* Der wichtigste Test der Datei. Telegram und Web-Push laufen über fremde
|
||||
* Server — dorthin gehört kein Gerät, kein Name, keine Inventarnummer.
|
||||
*/
|
||||
it("nimmt für externe Kanäle die Kurzfassung ohne Details", () => {
|
||||
for (const kanal of Object.values(Kanal).filter((wert) => KANAL_EXTERN[wert])) {
|
||||
const nachricht = nachrichtBauen({
|
||||
ereignis: EreignisTyp.PRUEFUNG_FAELLIG,
|
||||
kanal,
|
||||
werte: {
|
||||
wehr: "FF Musterhausen",
|
||||
anzahl: 3,
|
||||
geraet: "Pressluftatmer",
|
||||
inventarnummer: "INV-0042",
|
||||
faelligAm: "01.10.2026",
|
||||
tage: 7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nachricht.text, `Kanal ${kanal}`).not.toContain("INV-0042");
|
||||
expect(nachricht.text, `Kanal ${kanal}`).not.toContain("Pressluftatmer");
|
||||
expect(nachricht.text).toContain("3 Prüfungen");
|
||||
}
|
||||
});
|
||||
|
||||
it("nennt bei einem Mangel über einen externen Kanal die Beschreibung nicht", () => {
|
||||
const nachricht = nachrichtBauen({
|
||||
ereignis: EreignisTyp.MANGEL_GEMELDET,
|
||||
kanal: Kanal.TELEGRAM,
|
||||
werte: {
|
||||
wehr: "FF Musterhausen",
|
||||
geraet: "LF 20",
|
||||
inventarnummer: "INV-0001",
|
||||
beschreibung: "Bremsleitung undicht",
|
||||
prioritaet: "HOCH",
|
||||
melder: "Huber, Andreas",
|
||||
},
|
||||
});
|
||||
|
||||
expect(nachricht.text).not.toContain("Huber");
|
||||
expect(nachricht.text).not.toContain("Bremsleitung");
|
||||
expect(nachricht.text).toContain("HOCH");
|
||||
});
|
||||
|
||||
it("lässt eine eigene Vorlage gewinnen", () => {
|
||||
const nachricht = nachrichtBauen({
|
||||
ereignis: EreignisTyp.TERMIN_NEU,
|
||||
kanal: Kanal.MAIL,
|
||||
werte: { titel: "Übungsabend", datum: "15.09.2026" },
|
||||
vorlage: { betreff: "Neu: {titel}", text: "{titel} am {datum}. Kommst du?" },
|
||||
});
|
||||
|
||||
expect(nachricht.betreff).toBe("Neu: Übungsabend");
|
||||
expect(nachricht.text).toBe("Übungsabend am 15.09.2026. Kommst du?");
|
||||
});
|
||||
|
||||
it("greift ohne Vorlage auf den Standardbetreff zurück", () => {
|
||||
const nachricht = nachrichtBauen({
|
||||
ereignis: EreignisTyp.TERMIN_ABGESAGT,
|
||||
kanal: Kanal.MAIL,
|
||||
werte: { titel: "Waldfest", datum: "30.09.2026", uhrzeit: "14:00", grund: "Regen" },
|
||||
});
|
||||
|
||||
expect(nachricht.betreff).toBe("Abgesagt: Waldfest am 30.09.2026");
|
||||
expect(nachricht.text).toContain("Regen");
|
||||
});
|
||||
|
||||
it("liefert für jedes Ereignis einen Standardtext", () => {
|
||||
for (const ereignis of Object.values(EreignisTyp)) {
|
||||
expect(STANDARD[ereignis].text.length, ereignis).toBeGreaterThan(0);
|
||||
expect(STANDARD[ereignis].kurz.length, ereignis).toBeGreaterThan(0);
|
||||
expect(STANDARD[ereignis].betreff.length, ereignis).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Standardtext, der einen Platzhalter benutzt, den es für sein Ereignis
|
||||
* nicht gibt, würde bei jedem Empfänger als `{foo}` ankommen.
|
||||
*/
|
||||
it("benutzt in den Standardtexten nur erlaubte Platzhalter", () => {
|
||||
for (const ereignis of Object.values(EreignisTyp)) {
|
||||
const vorlage = STANDARD[ereignis];
|
||||
|
||||
expect(unbekanntePlatzhalter(ereignis, vorlage.betreff), ereignis).toEqual([]);
|
||||
expect(unbekanntePlatzhalter(ereignis, vorlage.text), ereignis).toEqual([]);
|
||||
expect(unbekanntePlatzhalter(ereignis, vorlage.kurz), ereignis).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unbekanntePlatzhalter", () => {
|
||||
it("findet einen Tippfehler", () => {
|
||||
expect(unbekanntePlatzhalter(EreignisTyp.TERMIN_NEU, "Am {datm} um {uhrzeit}")).toEqual([
|
||||
"datm",
|
||||
]);
|
||||
});
|
||||
|
||||
it("meldet jeden Fehler nur einmal", () => {
|
||||
expect(unbekanntePlatzhalter(EreignisTyp.TERMIN_NEU, "{foo} und {foo}")).toEqual(["foo"]);
|
||||
});
|
||||
|
||||
it("akzeptiert alle für das Ereignis vorgesehenen Platzhalter", () => {
|
||||
for (const ereignis of Object.values(EreignisTyp)) {
|
||||
const alle = PLATZHALTER[ereignis].map((name) => `{${name}}`).join(" ");
|
||||
expect(unbekanntePlatzhalter(ereignis, alle), ereignis).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("beanstandet einen Platzhalter, den es nur bei einem anderen Ereignis gibt", () => {
|
||||
// {inventarnummer} gibt es bei Prüfungen, nicht bei Terminen.
|
||||
expect(unbekanntePlatzhalter(EreignisTyp.TERMIN_NEU, "{inventarnummer}")).toEqual([
|
||||
"inventarnummer",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user