Files
Laufzettel/tests/datenbankregeln.test.ts
T
Florian WildeandClaude Opus 5 1be73b266c
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 22:50:32 +02:00

136 lines
5.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Wächter über die Datenbankobjekte, die Prisma nicht kennt (#009).
*
* Zusammengesetzte Fremdschlüssel, Trigramm-Indizes und Trigger lassen sich im
* Prisma-Schema nicht ausdrücken. Deshalb erzeugt `prisma migrate dev` bei
* **jeder** künftigen Migration Löschbefehle für sie — sie sind aus Prismas
* Sicht Abweichungen von der Vorgabe. Wer die Löschbefehle einmal übersieht,
* entfernt stillschweigend die Mandantengrenze, die Archivsuche oder den Schutz
* der Unveränderlichkeit.
*
* Dieser Test macht das laut. Er prüft nicht, dass die Regeln *wirken* — das tun
* `tests/datenmodell.test.ts` und `tests/vorlagen.test.ts` —, sondern nur, dass
* die Objekte überhaupt noch da sind.
*/
async function namen(abfrage: string): Promise<string[]> {
const zeilen = await testPrisma.$queryRawUnsafe<{ name: string }[]>(abfrage);
return zeilen.map((zeile) => zeile.name).sort();
}
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen — und ein
// Anhang an einem Schritt hängen, der zu einem anderen Lauf gehört.
expect(
await namen(
`SELECT conname AS name FROM pg_constraint
WHERE conname LIKE '%gleicher_kunde' OR conname LIKE '%gleicher_lauf'`,
),
).toEqual([
"Anhang_ergebnis_gleicher_lauf",
"Anhang_lauf_gleicher_kunde",
"Geraet_besitzer_gleicher_kunde",
"Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde",
"Lauf_mitarbeiter_gleicher_kunde",
"Mitarbeiter_vorgesetzter_gleicher_kunde",
]);
});
it("hat die Trigramm-Indizes für die Archivsuche", async () => {
// Ohne sie wird jede Archivsuche zum vollständigen Durchlauf der Tabelle.
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%gin_trgm_ops%'`,
),
).toEqual([
"Kommentar_rumpf_trgm_idx",
"Lauf_titel_trgm_idx",
"Schrittergebnis_notiz_trgm_idx",
"Schrittergebnis_wert_trgm_idx",
]);
});
it("hat die Trigger, die eine veröffentlichte Version schützen", async () => {
expect(
await namen(
`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname NOT LIKE '%_melden'`,
),
).toEqual([
"BausteinSchritt_unveraenderlich",
"BausteinVersion_status_einbahn",
"VorlagenAbschnitt_baustein_veroeffentlicht",
"VorlagenAbschnitt_unveraenderlich",
"VorlagenSchritt_unveraenderlich",
"VorlagenVariable_unveraenderlich",
"VorlagenVersion_status_einbahn",
]);
});
it("hat die Trigger, die eine Änderung am Lauf melden", async () => {
/*
* Ohne sie bleibt die Live-Aktualisierung still, und zwar **lautlos**: die
* Seite sieht aus wie immer, nur sieht niemand mehr die Arbeit des anderen.
* Welche Tabellen dabei sind, haengt an dem, was `laufZustand` liefert —
* fehlte `Kommentar` oder `Anhang`, waere der Strom gegenueber dem alten
* Polling ein Rueckschritt (#033).
*/
expect(
await namen(`SELECT tgname AS name FROM pg_trigger WHERE NOT tgisinternal AND tgname LIKE '%_melden'`),
).toEqual([
"Anhang_geaendert_melden",
"Kommentar_geaendert_melden",
"Lauf_geaendert_melden",
"Schrittergebnis_geaendert_melden",
]);
});
it("hat die partiellen Eindeutigkeitsindizes", async () => {
expect(
await namen(
`SELECT indexname AS name FROM pg_indexes WHERE schemaname='public' AND indexdef LIKE '%WHERE%'`,
),
).toEqual([
"BausteinVersion_ein_entwurf",
"Kunde_eigene_organisation_einmalig",
"Vorlage_seedSchluessel_einmalig",
"VorlagenVersion_ein_entwurf",
]);
});
it("hat alle Prüfbedingungen", async () => {
const vorhanden = await namen(
`SELECT conname AS name FROM pg_constraint WHERE contype='c' AND connamespace='public'::regnamespace`,
);
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
expect(vorhanden).toEqual([
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
"Einstellung_einzelne_zeile",
"Geraet_registriert_braucht_namen",
"Geraet_tagcode_form",
"Kunde_kuerzel_form",
"Lauf_abgeschlossen_mit_zeitpunkt",
"Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt",
"Lauf_mitarbeiter_eindeutig",
"Mitarbeiter_vorgesetzter_nicht_selbst",
"Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
"VorlagenVersion_veroeffentlicht_mit_zeitpunkt",
]);
});
it("hat die Erweiterung für die Trigramm-Suche", async () => {
const zeilen = await testPrisma.$queryRaw<{ extname: string }[]>`
SELECT extname FROM pg_extension WHERE extname = 'pg_trgm'
`;
expect(zeilen).toHaveLength(1);
});
});