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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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