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
@@ -72,14 +72,17 @@ test.describe("Anmeldung", () => {
|
||||
const navigation = page.getByRole("navigation", { name: "Hauptnavigation" });
|
||||
await expect(navigation.getByRole("link", { name: "Übersicht" })).toBeVisible();
|
||||
|
||||
// Die Karte steht seit Phase 6 zur Verfügung und ist ein Verweis.
|
||||
await expect(navigation.getByRole("link", { name: "Karte" })).toBeVisible();
|
||||
// Termine, Dienstausweis und Benachrichtigungen stehen seit Phase 7 zur
|
||||
// Verfügung und sind Verweise.
|
||||
await expect(navigation.getByRole("link", { name: "Termine" })).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Dienstausweis" })).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Benachrichtigungen" })).toBeVisible();
|
||||
|
||||
// Die Termine kommen erst in Phase 7 — der Eintrag ist sichtbar, aber kein
|
||||
// Die Kasse kommt erst in Phase 9 — der Eintrag ist sichtbar, aber kein
|
||||
// Verweis. Beim Abschluss einer Phase wandert diese Zusicherung eine Zeile
|
||||
// weiter nach unten.
|
||||
await expect(navigation.getByText("Termine")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Termine" })).toHaveCount(0);
|
||||
await expect(navigation.getByText("Kasse")).toBeVisible();
|
||||
await expect(navigation.getByRole("link", { name: "Kasse" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 7 — Termine, Benachrichtigungen und Dienstausweis.
|
||||
*
|
||||
* Drei Dinge stehen im Mittelpunkt, weil sie den Unterschied zwischen einem
|
||||
* brauchbaren und einem ärgerlichen Kalender ausmachen:
|
||||
*
|
||||
* 1. **Die Rückmeldung** — der Knopf, den jedes Mitglied drückt und sonst
|
||||
* nichts. Er muss ohne Umweg funktionieren und den Stand sofort zeigen.
|
||||
* 2. **Die Absage bleibt sichtbar.** Ein Termin, der ohne ein Wort
|
||||
* verschwindet, führt dazu, dass jemand vor dem verschlossenen Gerätehaus
|
||||
* steht.
|
||||
* 3. **Die Prüfseite des Ausweises ist ohne Anmeldung erreichbar** — und
|
||||
* weist einen erfundenen Code ab.
|
||||
*
|
||||
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
/** Eindeutiger Titel je Lauf, damit sich parallele Läufe nicht ins Gehege kommen. */
|
||||
function eigenerTitel(zweck: string): string {
|
||||
return `E2E-${zweck}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
async function anmelden(page: Page): Promise<void> {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await page.waitForURL("/");
|
||||
}
|
||||
|
||||
test.describe("Terminkalender", () => {
|
||||
test("zeigt die Termine aus dem Bestand", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Termine", level: 1 })).toBeVisible();
|
||||
|
||||
// Aus dem Seed: der Übungsabend als Serie und der Lehrgang mit Plätzen.
|
||||
await expect(page.getByRole("link", { name: "Übungsabend", exact: true }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt einen abgesagten Termin mit Grund statt ihn zu verstecken", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
const waldfest = page.getByRole("link", { name: "Waldfest der Feuerwehr" });
|
||||
await expect(waldfest).toBeVisible();
|
||||
|
||||
await waldfest.click();
|
||||
await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible();
|
||||
await expect(page.getByText(/Dauerregen angekündigt/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("nimmt eine Zusage entgegen und zeigt sie an", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
const jhv = page.getByRole("link", { name: "Jahreshauptversammlung" });
|
||||
await jhv.click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Jahreshauptversammlung" })).toBeVisible();
|
||||
|
||||
// "Absagen" exakt: sonst trifft es auch "Termin absagen" daneben.
|
||||
await page.getByRole("button", { name: "Zusagen", exact: true }).click();
|
||||
await expect(page.getByText("Aktuell: Zugesagt")).toBeVisible();
|
||||
|
||||
// Und wieder zurück — eine Rückmeldung muss sich ändern lassen.
|
||||
await page.getByRole("button", { name: "Absagen", exact: true }).click();
|
||||
await expect(page.getByText("Aktuell: Abgesagt")).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Lehrgang hat acht Plätze und im Seed elf Anmeldungen. Die Warteliste
|
||||
* ist damit der Regelfall, nicht der Sonderfall — sie muss sichtbar sein
|
||||
* und in der Reihenfolge der Anmeldung stehen.
|
||||
*
|
||||
* Bewusst keine eigene Zusage in diesem Test: ob der angemeldete Nutzer
|
||||
* schon einen Platz hat, hängt an der Reihenfolge im Seed. Was hier zählt,
|
||||
* ist die Anzeige der Warteliste — die gilt für alle.
|
||||
*/
|
||||
test("zeigt Belegung und Warteliste eines begrenzten Lehrgangs", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await page.getByRole("link", { name: "Maschinistenlehrgang Teil 1" }).click();
|
||||
|
||||
await expect(page.getByText(/von 8 belegt/)).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /^Zugesagt \(8\)$/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /^Warteliste \(\d+\)$/ })).toBeVisible();
|
||||
|
||||
// Die Warteliste ist nummeriert — sonst weiß niemand, wer nachrückt.
|
||||
await expect(page.getByText("1.", { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("legt einen Termin an", async ({ page }) => {
|
||||
const titel = eigenerTitel("Einzeltermin");
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await page.getByRole("button", { name: "Termin anlegen" }).click();
|
||||
await page.getByLabel("Titel").fill(titel);
|
||||
await page.getByLabel("Ort").fill("Gerätehaus");
|
||||
await page.getByRole("button", { name: "Anlegen" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: titel })).toBeVisible();
|
||||
await expect(page.getByText("Gerätehaus")).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Zahl, die vor dem Anlegen dasteht. Wer „wöchentlich bis in einem
|
||||
* Jahr" einstellt, soll vorher lesen, dass daraus über fünfzig Einträge
|
||||
* werden — nicht danach.
|
||||
*/
|
||||
test("nennt vor dem Anlegen, wie viele Termine eine Serie ergibt", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await page.getByRole("button", { name: "Termin anlegen" }).click();
|
||||
await page.getByLabel("Titel").fill(eigenerTitel("Serienvorschau"));
|
||||
|
||||
await page.getByLabel("Wiederholung").click();
|
||||
await page.getByRole("option", { name: "wöchentlich" }).click();
|
||||
|
||||
const inAchtWochen = new Date(Date.now() + 56 * 864e5);
|
||||
await page.getByLabel("Serie läuft bis").fill(inAchtWochen.toISOString().slice(0, 10));
|
||||
|
||||
await expect(page.getByText(/Das legt \d+ Termine an/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("verlangt für eine Absage einen Grund", async ({ page }) => {
|
||||
const titel = eigenerTitel("Absage");
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await page.getByRole("button", { name: "Termin anlegen" }).click();
|
||||
await page.getByLabel("Titel").fill(titel);
|
||||
await page.getByRole("button", { name: "Anlegen" }).click();
|
||||
await expect(page.getByRole("heading", { name: titel })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Termin absagen" }).click();
|
||||
|
||||
// Innerhalb des Dialogs suchen: "Absagen" steht auch auf der
|
||||
// Rückmeldekarte dahinter.
|
||||
const dialog = page.getByRole("dialog");
|
||||
|
||||
// Ohne Grund geht es nicht weiter.
|
||||
await expect(dialog.getByRole("button", { name: "Absagen", exact: true })).toBeDisabled();
|
||||
|
||||
await dialog.getByLabel("Grund").fill("Sturmwarnung, Übungsgelände nicht befahrbar.");
|
||||
await dialog.getByRole("button", { name: "Absagen", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Dieser Termin fällt aus.")).toBeVisible();
|
||||
await expect(page.getByText(/Sturmwarnung/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("führt eine Anwesenheitsliste über alle aktiven Mitglieder", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/termine");
|
||||
|
||||
await page.getByRole("link", { name: "Jahreshauptversammlung" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Anwesenheitsliste" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Anwesenheitsliste" })).toBeVisible();
|
||||
// Auf einem Blatt zum Abhaken steht jeder — nicht nur die Angemeldeten.
|
||||
await expect(page.getByRole("columnheader", { name: "Anwesend" })).toBeVisible();
|
||||
await expect(page.getByRole("row")).not.toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Benachrichtigungen", () => {
|
||||
test("sagt deutlich, dass ohne eigenes Zutun nichts kommt", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Benachrichtigungen", level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/Niemand wird ungefragt benachrichtigt/)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Kanal, der über einen fremden Server läuft, muss als solcher zu
|
||||
* erkennen sein — bevor jemand ihn einrichtet, nicht danach.
|
||||
*/
|
||||
test("kennzeichnet externe Kanäle", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen");
|
||||
|
||||
await page.getByLabel("Über").click();
|
||||
await expect(page.getByRole("option", { name: /Telegram/ })).toBeVisible();
|
||||
await expect(page.getByRole("option", { name: /Browser-Push/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("erzeugt eine Kalenderadresse, die genau einmal sichtbar ist", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen");
|
||||
|
||||
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("Kalender"));
|
||||
await page.getByRole("button", { name: "Adresse erzeugen" }).click();
|
||||
|
||||
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeVisible();
|
||||
|
||||
const feld = page.locator('input[readonly]');
|
||||
await expect(feld).toHaveValue(/\/api\/kalender\/.+\.ics$/);
|
||||
|
||||
// Nach dem Neuladen ist sie weg — gespeichert wird nur der Hash.
|
||||
await page.reload();
|
||||
await expect(page.getByText(/wird nur jetzt angezeigt/)).toBeHidden();
|
||||
});
|
||||
|
||||
test("liefert unter der Kalenderadresse eine gültige ICS-Datei", async ({ page, request }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen");
|
||||
|
||||
await page.getByLabel("Für welches Gerät?").fill(eigenerTitel("ICS"));
|
||||
await page.getByRole("button", { name: "Adresse erzeugen" }).click();
|
||||
|
||||
const adresse = await page.locator('input[readonly]').inputValue();
|
||||
const antwort = await request.get(adresse);
|
||||
|
||||
expect(antwort.status()).toBe(200);
|
||||
expect(antwort.headers()["content-type"]).toContain("text/calendar");
|
||||
|
||||
const inhalt = await antwort.text();
|
||||
expect(inhalt).toContain("BEGIN:VCALENDAR");
|
||||
expect(inhalt).toContain("Übungsabend");
|
||||
expect(inhalt).toContain("END:VCALENDAR");
|
||||
});
|
||||
|
||||
test("weist eine erfundene Kalenderadresse ab", async ({ request }) => {
|
||||
const antwort = await request.get("/api/kalender/nichtvorhanden.ics");
|
||||
expect(antwort.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("zeigt die Textvorlagen mit Platzhaltern und Vorschau", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen/vorlagen");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Textvorlagen", level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
|
||||
|
||||
// Die Platzhalter stehen zum Anklicken bereit — als Knopf, nicht nur im Text.
|
||||
await expect(page.getByRole("button", { name: "{titel}" })).toBeVisible();
|
||||
|
||||
// In der Vorschau stehen die Beispielwerte, nicht die Platzhalter.
|
||||
await expect(page.getByText("Übungsabend", { exact: false }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("warnt bei einem Platzhalter, den es für das Ereignis nicht gibt", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/benachrichtigungen/vorlagen");
|
||||
|
||||
const feld = page.getByLabel("Nachricht");
|
||||
await feld.fill("Am {datum} um {uhrzeit}. Und ein {tippfehler}.");
|
||||
await page.getByRole("button", { name: "Speichern" }).click();
|
||||
|
||||
await expect(page.getByTestId("vorlagenfehler")).toContainText("tippfehler");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Dienstausweis", () => {
|
||||
test("zeigt den eigenen Ausweis mit QR-Code", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/ausweis");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Dienstausweis", level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("img", { name: "Dienstausweis als QR-Code" })).toBeVisible();
|
||||
await expect(page.getByText(/Gültig noch/)).toBeVisible();
|
||||
await expect(page.getByText(/Nach fünf Minuten ist er wertlos/)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Prüfseite ist ohne Anmeldung erreichbar — wer an der Absperrung
|
||||
* scannt, hat keinen Zugang zu FLORIAN. Ein erfundener Code muss trotzdem
|
||||
* abgewiesen werden, und zwar mit einem Satz, den ein Mensch versteht.
|
||||
*/
|
||||
test("weist einen erfundenen Ausweis ab, ohne Anmeldung", async ({ browser }) => {
|
||||
const kontext = await browser.newContext();
|
||||
const seite = await kontext.newPage();
|
||||
|
||||
await seite.goto("/ausweis/ausweis.a.b.c.d");
|
||||
|
||||
await expect(seite.getByRole("heading", { name: "Ausweis nicht gültig" })).toBeVisible();
|
||||
await expect(seite.getByText(/Diese Seite zeigt an/)).toBeVisible();
|
||||
|
||||
await kontext.close();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUSWEIS_GUELTIG_SEKUNDEN,
|
||||
AUSWEIS_FEHLER_TEXT,
|
||||
ausweisErzeugen,
|
||||
ausweisPruefen,
|
||||
} from "@/lib/dienstausweis";
|
||||
import { markeErzeugen } from "@/lib/kiosk/anmeldemarke";
|
||||
|
||||
/**
|
||||
* Der Dienstausweis weist eine Person aus. Er verdient dieselbe Sorgfalt wie
|
||||
* ein Anmeldeweg — auch wenn er nichts öffnet, denn wer ihn fälschen kann,
|
||||
* kann an einer Absperrung behaupten, jemand anderes zu sein.
|
||||
*
|
||||
* Der interessanteste Test ist der letzte: Anmeldemarke und Ausweis sind mit
|
||||
* demselben Geheimnis signiert. Ohne den Zweck im signierten Teil ginge das
|
||||
* eine als das andere durch — dann wäre der Ausweis ein Anmeldeweg.
|
||||
*/
|
||||
|
||||
const PERSON = "8f7d3b1e-2c4a-4e6f-9a1b-0c2d4e6f8a1b";
|
||||
const ORGANISATION = "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Dienstausweis", () => {
|
||||
it("erkennt den eigenen Ausweis wieder", () => {
|
||||
const ergebnis = ausweisPruefen(ausweisErzeugen(PERSON, ORGANISATION));
|
||||
|
||||
expect(ergebnis).toMatchObject({ personId: PERSON, organisationId: ORGANISATION });
|
||||
expect("fehler" in ergebnis).toBe(false);
|
||||
});
|
||||
|
||||
it("nennt den Zeitpunkt der Ausstellung", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-04T10:00:00Z"));
|
||||
|
||||
const ergebnis = ausweisPruefen(ausweisErzeugen(PERSON, ORGANISATION));
|
||||
|
||||
expect("erzeugtAm" in ergebnis && ergebnis.erzeugtAm).toEqual(
|
||||
new Date("2026-09-04T10:00:00Z"),
|
||||
);
|
||||
});
|
||||
|
||||
it("weist einen Code ab, an dem jemand gedreht hat", () => {
|
||||
const code = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
const gefaelscht = code.replace(PERSON, "00000000-0000-4000-8000-000000000000");
|
||||
|
||||
expect(ausweisPruefen(gefaelscht)).toEqual({ fehler: "verfaelscht" });
|
||||
});
|
||||
|
||||
it("weist eine abgeschnittene Signatur ab", () => {
|
||||
const code = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
expect(ausweisPruefen(code.slice(0, -5))).toEqual({ fehler: "verfaelscht" });
|
||||
});
|
||||
|
||||
it("weist Unsinn ab", () => {
|
||||
expect(ausweisPruefen("")).toEqual({ fehler: "ungueltig" });
|
||||
expect(ausweisPruefen("hallo")).toEqual({ fehler: "ungueltig" });
|
||||
expect(ausweisPruefen("a.b.c.d")).toEqual({ fehler: "ungueltig" });
|
||||
});
|
||||
|
||||
it("gilt kurz vor Ablauf noch", () => {
|
||||
vi.useFakeTimers();
|
||||
const code = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
vi.advanceTimersByTime((AUSWEIS_GUELTIG_SEKUNDEN - 1) * 1000);
|
||||
|
||||
expect("personId" in ausweisPruefen(code)).toBe(true);
|
||||
});
|
||||
|
||||
it("gilt eine Sekunde nach Ablauf nicht mehr", () => {
|
||||
vi.useFakeTimers();
|
||||
const code = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
vi.advanceTimersByTime((AUSWEIS_GUELTIG_SEKUNDEN + 1) * 1000);
|
||||
|
||||
expect(ausweisPruefen(code)).toEqual({ fehler: "abgelaufen" });
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Code aus der Zukunft ist kein gültiger Code. Ohne diese Prüfung
|
||||
* könnte jemand mit einem gefälschten Zeitstempel einen Ausweis bauen, der
|
||||
* beliebig lange gilt — sofern er an das Geheimnis käme.
|
||||
*/
|
||||
it("nimmt keinen Code aus der Zukunft an", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-09-04T12:00:00Z"));
|
||||
const code = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
vi.setSystemTime(new Date("2026-09-04T11:00:00Z"));
|
||||
|
||||
expect(ausweisPruefen(code)).toEqual({ fehler: "abgelaufen" });
|
||||
});
|
||||
|
||||
it("erzeugt bei jedem Aufruf einen anderen Code", () => {
|
||||
vi.useFakeTimers();
|
||||
const erster = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
vi.advanceTimersByTime(1000);
|
||||
const zweiter = ausweisErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
expect(erster).not.toBe(zweiter);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Ausweis darf nicht als Anmeldemarke durchgehen und umgekehrt. Beide
|
||||
* sind mit AUTH_SECRET signiert — was sie trennt, ist der Zweck im
|
||||
* signierten Teil.
|
||||
*/
|
||||
it("nimmt keine Anmeldemarke als Ausweis an", () => {
|
||||
const marke = markeErzeugen(PERSON, ORGANISATION);
|
||||
|
||||
expect(ausweisPruefen(marke)).toEqual({ fehler: "ungueltig" });
|
||||
});
|
||||
|
||||
it("hat für jeden Fehler einen Satz, den ein Mensch versteht", () => {
|
||||
for (const text of Object.values(AUSWEIS_FEHLER_TEXT)) {
|
||||
expect(text.length).toBeGreaterThan(10);
|
||||
expect(text.endsWith(".")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Was die Datenbank bei Terminen und Benachrichtigungen durchsetzt.
|
||||
*
|
||||
* Vier Dinge, die nicht im Anwendungscode stehen dürfen, weil sie auch dann
|
||||
* gelten müssen, wenn jemand am Router vorbei schreibt:
|
||||
*
|
||||
* 1. Unmögliche Termine werden abgewiesen: Ende vor Beginn, Anmeldeschluss
|
||||
* nach dem Termin, ein Kontingent von null Plätzen.
|
||||
* 2. Eine Person meldet sich zu einem Termin genau einmal zurück.
|
||||
* 3. Verschickte Benachrichtigungen lassen sich nicht löschen — wer wann
|
||||
* worüber informiert wurde, ist im Zweifel die entscheidende Frage.
|
||||
* 4. Mandantentrennung der sechs 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-termin-wehr";
|
||||
|
||||
let erreichbar = false;
|
||||
let organisationId = "";
|
||||
let personA = "";
|
||||
let personB = "";
|
||||
|
||||
const BEGINN = new Date("2026-10-13T17:00:00.000Z");
|
||||
|
||||
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: "Termin-Testwehr", kurzname: "TTW" },
|
||||
});
|
||||
organisationId = organisation.id;
|
||||
|
||||
const anlegen = async (nummer: string, vorname: string) => {
|
||||
const person = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } },
|
||||
update: {},
|
||||
create: { organisationId, mitgliedsnummer: nummer, vorname, nachname: "Termin" },
|
||||
});
|
||||
return person.id;
|
||||
};
|
||||
|
||||
personA = await anlegen("T001", "Anton");
|
||||
personB = await anlegen("T002", "Berta");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||
});
|
||||
}
|
||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||
});
|
||||
|
||||
/** Ein Termin mit sinnvollen Vorgabewerten. */
|
||||
async function terminAnlegen(felder: Record<string, unknown> = {}) {
|
||||
return system.termin.create({
|
||||
data: { organisationId, titel: "Testtermin", beginn: BEGINN, ...felder },
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Die Datenbank weist unmögliche Termine ab", () => {
|
||||
it("nimmt einen gewöhnlichen Termin an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const termin = await terminAnlegen({
|
||||
titel: "Übungsabend",
|
||||
ende: new Date("2026-10-13T19:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(termin.titel).toBe("Übungsabend");
|
||||
});
|
||||
|
||||
it("weist ein Ende vor dem Beginn ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
terminAnlegen({ ende: new Date("2026-10-13T15:00:00.000Z") }),
|
||||
).rejects.toThrow(/termin_ende_nach_beginn/);
|
||||
});
|
||||
|
||||
it("weist einen Anmeldeschluss nach dem Termin ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
terminAnlegen({ anmeldeschluss: new Date("2026-10-20T00:00:00.000Z") }),
|
||||
).rejects.toThrow(/termin_anmeldeschluss_vorher/);
|
||||
});
|
||||
|
||||
it("weist ein Kontingent von null Plätzen ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Null Plätze hieße: alle auf die Warteliste, niemand kommt je nach.
|
||||
// Wer den Termin niemandem geben will, sagt ihn ab.
|
||||
await expect(terminAnlegen({ teilnehmerLimit: 0 })).rejects.toThrow(
|
||||
/termin_limit_sinnvoll/,
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt ein Ende genau zum Beginn zu", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Ein Termin ohne Dauer ist nicht sinnlos: so trägt man einen Stichtag ein.
|
||||
const termin = await terminAnlegen({ ende: BEGINN });
|
||||
expect(termin.ende).toEqual(BEGINN);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Rückmeldungen", () => {
|
||||
it("lässt eine Person nur eine Rückmeldung je Termin abgeben", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const termin = await terminAnlegen({ titel: "Einmalig", anmeldungNoetig: true });
|
||||
|
||||
await system.terminanmeldung.create({
|
||||
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.terminanmeldung.create({
|
||||
data: { organisationId, terminId: termin.id, personId: personA, stand: "ABGESAGT" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("erlaubt verschiedenen Personen dieselbe Rückmeldung", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const termin = await terminAnlegen({ titel: "Zu zweit", anmeldungNoetig: true });
|
||||
|
||||
await system.terminanmeldung.create({
|
||||
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
|
||||
});
|
||||
const zweite = await system.terminanmeldung.create({
|
||||
data: { organisationId, terminId: termin.id, personId: personB, stand: "ZUGESAGT" },
|
||||
});
|
||||
|
||||
expect(zweite.stand).toBe("ZUGESAGT");
|
||||
});
|
||||
|
||||
it("verschwindet mit dem Termin", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const termin = await terminAnlegen({ titel: "Wird entfernt" });
|
||||
await system.terminanmeldung.create({
|
||||
data: { organisationId, terminId: termin.id, personId: personA, stand: "ZUGESAGT" },
|
||||
});
|
||||
|
||||
await system.termin.delete({ where: { id: termin.id } });
|
||||
|
||||
const uebrig = await system.terminanmeldung.count({ where: { terminId: termin.id } });
|
||||
expect(uebrig).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Benachrichtigungen sind nachvollziehbar", () => {
|
||||
it("nimmt eine Benachrichtigung an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const eintrag = await system.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
ereignis: "TERMIN_NEU",
|
||||
kanal: "MAIL",
|
||||
text: "Ein neuer Termin.",
|
||||
ziel: "anton@example.org",
|
||||
},
|
||||
});
|
||||
|
||||
expect(eintrag.stand).toBe("WARTET");
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE ist der Anwendungsrolle entzogen. Wer wann worüber informiert
|
||||
* wurde, soll sich nicht über die Oberfläche wegräumen lassen — die
|
||||
* Aufbewahrungsfrist räumt auf, und die läuft über den Systemzugang.
|
||||
*/
|
||||
it("lässt sich über die Anwendungsrolle nicht löschen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const eintrag = await system.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
ereignis: "MANGEL_GEMELDET",
|
||||
kanal: "MAIL",
|
||||
text: "Ein Mangel.",
|
||||
ziel: "anton@example.org",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
await tx.benachrichtigung.delete({ where: { id: eintrag.id } });
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("weist eine negative Versuchszahl ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.benachrichtigung.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
ereignis: "TERMIN_NEU",
|
||||
kanal: "MAIL",
|
||||
text: "Unsinn",
|
||||
ziel: "x",
|
||||
versuche: -1,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/benachrichtigung_versuche_plausibel/);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der neuen Tabellen", () => {
|
||||
it("liefert ohne gesetzten Mandanten nichts", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await terminAnlegen({ titel: "Unsichtbar ohne Kontext" });
|
||||
|
||||
// Ohne app.current_organisation greift die Row Level Security: null Zeilen.
|
||||
const gefunden = await app.termin.findMany({ where: { organisationId } });
|
||||
expect(gefunden).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("liefert mit gesetztem Mandanten die eigenen Termine", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const gefunden = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.termin.findMany({ where: { organisationId } });
|
||||
});
|
||||
|
||||
expect(gefunden.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("verbirgt die Termine einer fremden Wehr", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const fremde = await system.organisation.upsert({
|
||||
where: { slug: "test-termin-fremde" },
|
||||
update: {},
|
||||
create: { slug: "test-termin-fremde", name: "Fremde Wehr", kurzname: "FW" },
|
||||
});
|
||||
|
||||
await system.termin.create({
|
||||
data: { organisationId: fremde.id, titel: "Nicht für uns", beginn: BEGINN },
|
||||
});
|
||||
|
||||
const gefunden = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.termin.findMany({ where: { titel: "Nicht für uns" } });
|
||||
});
|
||||
|
||||
expect(gefunden).toHaveLength(0);
|
||||
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: "test-termin-fremde" } });
|
||||
});
|
||||
});
|
||||
|
||||
it("schützt auch Zustellkanäle und Kalenderzugänge", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await system.zustellkanal.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
kanal: "NTFY",
|
||||
ziel: "florian-geheim",
|
||||
ereignisse: ["TERMIN_NEU"],
|
||||
},
|
||||
});
|
||||
|
||||
await system.kalenderzugang.create({
|
||||
data: {
|
||||
organisationId,
|
||||
personId: personA,
|
||||
bezeichnung: "Testhandy",
|
||||
tokenHash: "a".repeat(64),
|
||||
},
|
||||
});
|
||||
|
||||
const [kanaele, zugaenge] = await Promise.all([
|
||||
app.zustellkanal.findMany({ where: { organisationId } }),
|
||||
app.kalenderzugang.findMany({ where: { organisationId } }),
|
||||
]);
|
||||
|
||||
expect(kanaele).toHaveLength(0);
|
||||
expect(zugaenge).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminArt } from "@prisma/client";
|
||||
import { icsErzeugen, type IcsTermin } from "@/lib/termin/ics";
|
||||
|
||||
/**
|
||||
* Die Kalenderdatei.
|
||||
*
|
||||
* Ein selbst geschriebener Formatierer ist nur so viel wert wie seine Tests:
|
||||
* ein nicht maskiertes Komma zerlegt beim Empfänger den ganzen Eintrag, und
|
||||
* ein fehlendes CRLF lässt manche Kalender die Datei gar nicht erst annehmen.
|
||||
* Beides sieht man der Ausgabe beim Drüberschauen nicht an.
|
||||
*/
|
||||
|
||||
function termin(felder: Partial<IcsTermin> = {}): IcsTermin {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
titel: "Übungsabend",
|
||||
beschreibung: null,
|
||||
ort: "Gerätehaus",
|
||||
beginn: new Date(Date.UTC(2026, 8, 15, 17, 30)),
|
||||
ende: new Date(Date.UTC(2026, 8, 15, 19, 30)),
|
||||
ganztaegig: false,
|
||||
art: TerminArt.UEBUNG,
|
||||
abgesagt: false,
|
||||
geaendertAm: new Date(Date.UTC(2026, 8, 1, 10, 0)),
|
||||
...felder,
|
||||
};
|
||||
}
|
||||
|
||||
function erzeugen(termine: IcsTermin[]): string {
|
||||
return icsErzeugen({ name: "FF Musterhausen", termine, adresse: "https://florian.example" });
|
||||
}
|
||||
|
||||
describe("icsErzeugen", () => {
|
||||
it("baut ein vollständiges Kalendergerüst", () => {
|
||||
const datei = erzeugen([termin()]);
|
||||
|
||||
expect(datei).toContain("BEGIN:VCALENDAR");
|
||||
expect(datei).toContain("VERSION:2.0");
|
||||
expect(datei).toContain("END:VCALENDAR");
|
||||
expect(datei).toContain("BEGIN:VEVENT");
|
||||
expect(datei).toContain("END:VEVENT");
|
||||
});
|
||||
|
||||
/**
|
||||
* RFC 5545 verlangt CRLF. Manche Kalender nehmen es genau — und wer das
|
||||
* versehentlich auf LF ändert, merkt es erst, wenn sich jemand beschwert,
|
||||
* dass sein iPhone die Datei nicht öffnet.
|
||||
*/
|
||||
it("beendet jede Zeile mit CRLF", () => {
|
||||
const datei = erzeugen([termin()]);
|
||||
|
||||
expect(datei.endsWith("\r\n")).toBe(true);
|
||||
// Kein einziges nacktes LF.
|
||||
expect(datei.replace(/\r\n/g, "")).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("schreibt Zeitstempel in UTC ohne Trennzeichen", () => {
|
||||
const datei = erzeugen([termin()]);
|
||||
|
||||
expect(datei).toContain("DTSTART:20260915T173000Z");
|
||||
expect(datei).toContain("DTEND:20260915T193000Z");
|
||||
expect(datei).toContain("DTSTAMP:20260901T100000Z");
|
||||
});
|
||||
|
||||
it("macht aus einem ganztägigen Termin ein Datum mit ausschließendem Ende", () => {
|
||||
const datei = erzeugen([
|
||||
termin({
|
||||
ganztaegig: true,
|
||||
beginn: new Date(Date.UTC(2026, 8, 15, 0, 0)),
|
||||
ende: new Date(Date.UTC(2026, 8, 15, 0, 0)),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(datei).toContain("DTSTART;VALUE=DATE:20260915");
|
||||
// Das Ende ist ausschließend: ein Tag mehr, sonst fehlt der Termin im
|
||||
// Kalender ganz oder dauert null Tage.
|
||||
expect(datei).toContain("DTEND;VALUE=DATE:20260916");
|
||||
});
|
||||
|
||||
it("maskiert Komma, Semikolon und Backslash im Titel", () => {
|
||||
const datei = erzeugen([termin({ titel: "Übung: Keller, Dach; Hof \\ Halle" })]);
|
||||
|
||||
expect(datei).toContain("Keller\\, Dach\\; Hof \\\\ Halle");
|
||||
});
|
||||
|
||||
it("macht aus Zeilenumbrüchen in der Beschreibung \\n", () => {
|
||||
const datei = erzeugen([termin({ beschreibung: "Erste Zeile\nZweite Zeile" })]);
|
||||
|
||||
expect(datei).toContain("Erste Zeile\\nZweite Zeile");
|
||||
// Der Umbruch darf nicht als echter Zeilenumbruch im Feld landen.
|
||||
expect(datei).not.toContain("DESCRIPTION:Erste Zeile\r\nZweite");
|
||||
});
|
||||
|
||||
/**
|
||||
* Lange Zeilen werden mit einem führenden Leerzeichen fortgesetzt. Ohne das
|
||||
* lehnen strenge Kalender die Datei ab.
|
||||
*/
|
||||
it("bricht Zeilen über 75 Oktette um", () => {
|
||||
const datei = erzeugen([termin({ ort: "A".repeat(200) })]);
|
||||
|
||||
for (const zeile of datei.split("\r\n")) {
|
||||
// Fortsetzungszeilen beginnen mit einem Leerzeichen und zählen ihr
|
||||
// eigenes Limit — geprüft wird, dass keine Zeile zu lang ist.
|
||||
expect(zeile.length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
|
||||
expect(datei).toContain("\r\n A");
|
||||
});
|
||||
|
||||
it("kennzeichnet einen abgesagten Termin doppelt", () => {
|
||||
const datei = erzeugen([termin({ abgesagt: true })]);
|
||||
|
||||
// Einmal maschinenlesbar …
|
||||
expect(datei).toContain("STATUS:CANCELLED");
|
||||
// … und einmal im Titel, weil viele Kalender den Status ignorieren und
|
||||
// der Termin sonst ganz normal aussieht.
|
||||
expect(datei).toContain("SUMMARY:ABGESAGT: Übungsabend");
|
||||
});
|
||||
|
||||
it("setzt CONFIRMED, solange nichts abgesagt ist", () => {
|
||||
expect(erzeugen([termin()])).toContain("STATUS:CONFIRMED");
|
||||
});
|
||||
|
||||
it("nennt den eigenen Stand in der Beschreibung", () => {
|
||||
expect(erzeugen([termin({ eigenerStand: "ZUGESAGT" })])).toContain("Du bist zugesagt.");
|
||||
expect(erzeugen([termin({ eigenerStand: "WARTELISTE" })])).toContain(
|
||||
"Du stehst auf der Warteliste.",
|
||||
);
|
||||
});
|
||||
|
||||
it("gibt jedem Termin eine eindeutige Kennung", () => {
|
||||
const datei = erzeugen([
|
||||
termin({ id: "aaaa" }),
|
||||
termin({ id: "bbbb", titel: "Arbeitsdienst" }),
|
||||
]);
|
||||
|
||||
expect(datei).toContain("UID:aaaa@florian");
|
||||
expect(datei).toContain("UID:bbbb@florian");
|
||||
expect(datei.match(/BEGIN:VEVENT/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("kommt mit einem leeren Kalender zurecht", () => {
|
||||
const datei = erzeugen([]);
|
||||
|
||||
expect(datei).toContain("BEGIN:VCALENDAR");
|
||||
expect(datei).toContain("END:VCALENDAR");
|
||||
expect(datei).not.toContain("BEGIN:VEVENT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Serienmuster } from "@prisma/client";
|
||||
import { HOECHSTZAHL, serienlaenge, serientermine } from "@/lib/termin/serie";
|
||||
|
||||
/**
|
||||
* Serientermine.
|
||||
*
|
||||
* Geprüft wird gegen den Kalender, nicht gegen die Implementierung: „monatlich"
|
||||
* heißt derselbe Tag im nächsten Monat, und der 31. Januar landet im Februar
|
||||
* auf dem 28. — nicht im März, wie es passiert, wenn man stur Monate addiert.
|
||||
*/
|
||||
|
||||
const D = (jahr: number, monat: number, tag: number, stunde = 19) =>
|
||||
new Date(Date.UTC(jahr, monat - 1, tag, stunde, 0, 0));
|
||||
|
||||
describe("serientermine", () => {
|
||||
it("gibt bei KEINE genau den einen Termin zurück", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10),
|
||||
muster: Serienmuster.KEINE,
|
||||
bis: D(2027, 3, 10),
|
||||
});
|
||||
|
||||
expect(termine).toHaveLength(1);
|
||||
expect(termine[0]).toEqual(D(2026, 3, 10));
|
||||
});
|
||||
|
||||
it("legt wöchentlich immer denselben Wochentag", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10), // Dienstag
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2026, 4, 7),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([
|
||||
D(2026, 3, 10),
|
||||
D(2026, 3, 17),
|
||||
D(2026, 3, 24),
|
||||
D(2026, 3, 31),
|
||||
D(2026, 4, 7),
|
||||
]);
|
||||
|
||||
// Alle am selben Wochentag — das ist der Punkt einer Serie.
|
||||
for (const termin of termine) {
|
||||
expect(termin.getUTCDay()).toBe(D(2026, 3, 10).getUTCDay());
|
||||
}
|
||||
});
|
||||
|
||||
it("überspringt bei ZWEIWOECHENTLICH jede zweite Woche", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10),
|
||||
muster: Serienmuster.ZWEIWOECHENTLICH,
|
||||
bis: D(2026, 4, 30),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([D(2026, 3, 10), D(2026, 3, 24), D(2026, 4, 7), D(2026, 4, 21)]);
|
||||
});
|
||||
|
||||
it("behält die Uhrzeit über die ganze Serie", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10, 19),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2026, 4, 30),
|
||||
});
|
||||
|
||||
for (const termin of termine) {
|
||||
expect(termin.getUTCHours()).toBe(19);
|
||||
expect(termin.getUTCMinutes()).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("rechnet monatlich auf dem Kalender, nicht in 30-Tage-Schritten", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 1, 15),
|
||||
muster: Serienmuster.MONATLICH,
|
||||
bis: D(2026, 5, 20),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([
|
||||
D(2026, 1, 15),
|
||||
D(2026, 2, 15),
|
||||
D(2026, 3, 15),
|
||||
D(2026, 4, 15),
|
||||
D(2026, 5, 15),
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Fall, an dem jede naive Umsetzung scheitert: setMonth(+1) auf dem
|
||||
* 31. Januar ergibt den 3. März, weil der Februar keinen 31. hat. Ein
|
||||
* Mensch erwartet den letzten Februartag.
|
||||
*/
|
||||
it("schiebt den 31. nicht in den übernächsten Monat", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 1, 31),
|
||||
muster: Serienmuster.MONATLICH,
|
||||
bis: D(2026, 4, 30),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([
|
||||
D(2026, 1, 31),
|
||||
D(2026, 2, 28), // kein 31. Februar
|
||||
D(2026, 3, 31),
|
||||
D(2026, 4, 30), // kein 31. April
|
||||
]);
|
||||
});
|
||||
|
||||
it("kennt den 29. Februar im Schaltjahr", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2024, 2, 29),
|
||||
muster: Serienmuster.JAEHRLICH,
|
||||
bis: D(2028, 3, 1),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([
|
||||
D(2024, 2, 29),
|
||||
D(2025, 2, 28), // kein Schaltjahr
|
||||
D(2026, 2, 28),
|
||||
D(2027, 2, 28),
|
||||
D(2028, 2, 29), // wieder Schaltjahr
|
||||
]);
|
||||
});
|
||||
|
||||
it("nimmt einen Termin am Endtag noch mit", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2026, 3, 24),
|
||||
});
|
||||
|
||||
expect(termine.at(-1)).toEqual(D(2026, 3, 24));
|
||||
});
|
||||
|
||||
it("liefert bei einem Endtag vor dem Beginn nur den Beginn", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 3, 10),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2026, 1, 1),
|
||||
});
|
||||
|
||||
expect(termine).toEqual([D(2026, 3, 10)]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Eine Serie, die versehentlich bis 2099 läuft, darf nicht zehntausend
|
||||
* Zeilen anlegen. Die Oberfläche zeigt vorher an, dass gekappt wird.
|
||||
*/
|
||||
it("kappt bei der Höchstzahl", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 1, 1),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2099, 1, 1),
|
||||
});
|
||||
|
||||
expect(termine).toHaveLength(HOECHSTZAHL);
|
||||
});
|
||||
|
||||
it("achtet eine niedrigere eigene Höchstzahl", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 1, 1),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2099, 1, 1),
|
||||
hoechstzahl: 5,
|
||||
});
|
||||
|
||||
expect(termine).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("lässt sich die Höchstzahl nicht hochsetzen", () => {
|
||||
const termine = serientermine({
|
||||
beginn: D(2026, 1, 1),
|
||||
muster: Serienmuster.WOECHENTLICH,
|
||||
bis: D(2099, 1, 1),
|
||||
hoechstzahl: 10_000,
|
||||
});
|
||||
|
||||
expect(termine).toHaveLength(HOECHSTZAHL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serienlaenge", () => {
|
||||
it("zählt dasselbe, was serientermine liefert", () => {
|
||||
const angaben = {
|
||||
beginn: D(2026, 3, 10),
|
||||
muster: Serienmuster.ZWEIWOECHENTLICH,
|
||||
bis: D(2026, 9, 10),
|
||||
};
|
||||
|
||||
expect(serienlaenge(angaben)).toBe(serientermine(angaben).length);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user