Files
FireAdministration/tests/unit/ki-datenbank.test.ts
T
Florian WildeandClaude Opus 5 ff40437a1a Phase 10: Mandantenfaehigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine
Kreisebene, die zusieht.

Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war
alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto,
und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte
zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig.

Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit).
Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es
ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt
wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase
0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft.

Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven
Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien
musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht:
`personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat,
und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen
die Kreisaufsicht nichts verloren hat.

Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst,
minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie
neue Leserechte automatisch und neue Schreibrechte nie.

Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand
zu hinterlassen.

Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der
Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war
nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte
ausloesen, bevor eine Seite ueberhaupt geladen war.

581 Unit-Tests und 266 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:43:24 +02:00

446 lines
15 KiB
TypeScript

import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Was die Datenbank bei den KI-Daten durchsetzt.
*
* Die Regel dieser Phase — nichts Maschinelles gelangt ungeprüft in einen
* Nachweis — steht nicht nur im Anwendungscode. Sie steht in der Datenbank,
* und das ist der Unterschied zwischen einer Absicht und einer Zusicherung:
*
* 1. Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem
* Anlegen unveränderlich.
* 2. Über einen Vorschlag wird genau einmal entschieden.
* 3. Ein Vorgang lässt sich über die Anwendungsrolle nicht löschen.
* 4. Eine Entscheidung ohne Zeitpunkt gibt es nicht.
* 5. Frühere Fassungen einer Wiki-Seite bleiben.
* 6. Der Vektorindex findet den ähnlichsten Abschnitt.
*
* 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-ki-wehr";
let erreichbar = false;
let organisationId = "";
let personId = "";
let seiteId = "";
/** Ein Vektor mit 768 Stellen, dessen erste Werte gesetzt sind. */
function vektor(...werte: number[]): string {
const alle = Array.from({ length: 768 }, (_, stelle) => werte[stelle] ?? 0);
return `[${alle.join(",")}]`;
}
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: "KI-Testwehr", kurzname: "KTW" },
});
organisationId = organisation.id;
const person = await system.person.upsert({
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "K001" } },
update: {},
create: { organisationId, mitgliedsnummer: "K001", vorname: "Kai", nachname: "Testwart" },
});
personId = person.id;
// Wie Wehr und Person darüber: idempotent. Bleibt aus einem abgebrochenen
// Lauf etwas stehen, scheiterte ein bloßes `create` hier an der
// Eindeutigkeit — und der ganze Datei-Lauf mit ihm, aus einem Grund, der
// mit dem Geprüften nichts zu tun hat.
const seite = await system.wikiseite.upsert({
where: { organisationId_slug: { organisationId, slug: "testseite" } },
update: {},
create: {
organisationId,
slug: "testseite",
titel: "Testseite",
inhalt: "# Test\n\nInhalt.",
},
});
seiteId = seite.id;
});
afterAll(async () => {
if (erreichbar) {
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
// Der Unveränderlichkeits-Trigger sitzt auf ki_vorgang. Für das
// Aufräumen der Testdaten wird er kurz abgeschaltet; im Betrieb
// passiert das nie.
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" DISABLE TRIGGER USER');
await tx.organisation.deleteMany({ where: { slug: SLUG } });
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" ENABLE TRIGGER USER');
});
}
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
});
async function vorgangAnlegen(felder: Record<string, unknown> = {}) {
return system.kiVorgang.create({
data: {
organisationId,
art: "BERICHTSENTWURF",
modell: "qwen2.5:14b",
eingabe: "Stichpunkte",
ausgabe: "Ein Entwurf.",
erzeugtVonId: personId,
...felder,
},
});
}
describe.runIf(process.env.DATABASE_URL)("Ein KI-Vorgang hält fest, was war", () => {
it("nimmt einen Vorgang an", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
expect(vorgang.stand).toBe("ENTWURF");
expect(vorgang.entschiedenAm).toBeNull();
});
it("weist eine Änderung der Ausgabe ab", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
await expect(
system.kiVorgang.update({
where: { id: vorgang.id },
data: { ausgabe: "Etwas ganz anderes." },
}),
).rejects.toThrow(/nicht nachtraeglich aendern/);
});
it("weist eine Änderung des Modells ab", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
await expect(
system.kiVorgang.update({ where: { id: vorgang.id }, data: { modell: "anderes" } }),
).rejects.toThrow(/nicht nachtraeglich aendern/);
});
it("lässt die Entscheidung zu", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
const nachher = await system.kiVorgang.update({
where: { id: vorgang.id },
data: { stand: "UEBERNOMMEN", entschiedenVonId: personId, entschiedenAm: new Date() },
});
expect(nachher.stand).toBe("UEBERNOMMEN");
});
/**
* Eine zweite Entscheidung wäre nicht nachvollziehbar: hinterher stünde
* „verworfen" an einem Text, der längst im Bericht steht.
*/
it("lässt über einen Vorschlag nur einmal entscheiden", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
await system.kiVorgang.update({
where: { id: vorgang.id },
data: { stand: "UEBERNOMMEN", entschiedenVonId: personId, entschiedenAm: new Date() },
});
await expect(
system.kiVorgang.update({ where: { id: vorgang.id }, data: { stand: "VERWORFEN" } }),
).rejects.toThrow(/bereits entschieden/);
});
it("weist eine Entscheidung ohne Zeitpunkt ab", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
await expect(
system.kiVorgang.update({ where: { id: vorgang.id }, data: { stand: "UEBERNOMMEN" } }),
).rejects.toThrow(/entscheidung_vollstaendig/);
});
it("verlangt zu einem Fehlschlag einen Grund", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
vorgangAnlegen({ stand: "FEHLGESCHLAGEN", ausgabe: null, fehler: null }),
).rejects.toThrow(/fehler_begruendet/);
});
it("nimmt einen Fehlschlag mit Grund an", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen({
stand: "FEHLGESCHLAGEN",
ausgabe: null,
fehler: "Das Modell hat nach 120 Sekunden nicht geantwortet.",
});
expect(vorgang.fehler).toContain("120 Sekunden");
});
it("lässt sich über die Anwendungsrolle nicht löschen", async ({ skip }) => {
if (!erreichbar) return skip();
const vorgang = await vorgangAnlegen();
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
await tx.kiVorgang.delete({ where: { id: vorgang.id } });
}),
).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Wiki-Fassungen bleiben", () => {
it("nimmt eine Fassung an", async ({ skip }) => {
if (!erreichbar) return skip();
const fassung = await system.wikiversion.create({
data: { organisationId, seiteId, nummer: 1, titel: "Alt", inhalt: "Alter Text." },
});
expect(fassung.nummer).toBe(1);
});
it("weist eine zweite Fassung mit derselben Nummer ab", async ({ skip }) => {
if (!erreichbar) return skip();
await system.wikiversion.create({
data: { organisationId, seiteId, nummer: 7, titel: "A", inhalt: "A" },
});
await expect(
system.wikiversion.create({
data: { organisationId, seiteId, nummer: 7, titel: "B", inhalt: "B" },
}),
).rejects.toThrow();
});
it("weist die Nummer null ab", async ({ skip }) => {
if (!erreichbar) return skip();
await expect(
system.wikiversion.create({
data: { organisationId, seiteId, nummer: 0, titel: "A", inhalt: "A" },
}),
).rejects.toThrow(/nummer_positiv/);
});
it("lässt sich über die Anwendungsrolle weder ändern noch löschen", async ({ skip }) => {
if (!erreichbar) return skip();
const fassung = await system.wikiversion.create({
data: { organisationId, seiteId, nummer: 99, titel: "Fest", inhalt: "Fest." },
});
await expect(
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
await tx.wikiversion.update({ where: { id: fassung.id }, data: { inhalt: "geändert" } });
}),
).rejects.toThrow();
});
});
describe.runIf(process.env.DATABASE_URL)("Die semantische Suche", () => {
it("legt Einbettungen an und findet den ähnlichsten Abschnitt", async ({ skip }) => {
if (!erreichbar) return skip();
// Drei Abschnitte mit klar verschiedenen Vektoren. Der erste zeigt in
// dieselbe Richtung wie die spätere Suchanfrage.
await system.$executeRawUnsafe(
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 0, 'Nah dran', $3::vector, now()),
(gen_random_uuid(), $1::uuid, $2::uuid, 1, 'Weit weg', $4::vector, now()),
(gen_random_uuid(), $1::uuid, $2::uuid, 2, 'Dazwischen', $5::vector, now())`,
organisationId,
seiteId,
vektor(1, 0, 0),
vektor(0, 0, 1),
vektor(0.7, 0.7, 0),
);
const treffer = await system.$queryRawUnsafe<Array<{ text: string; abstand: number }>>(
`SELECT "text", ("einbettung" <=> $1::vector) AS "abstand"
FROM "wikiabschnitt"
WHERE "organisationId" = $2::uuid AND "einbettung" IS NOT NULL
ORDER BY "einbettung" <=> $1::vector
LIMIT 3`,
vektor(1, 0, 0),
organisationId,
);
expect(treffer[0]!.text).toBe("Nah dran");
expect(Number(treffer[0]!.abstand)).toBeCloseTo(0, 5);
expect(treffer.at(-1)!.text).toBe("Weit weg");
});
it("weist einen Vektor mit falscher Länge ab", async ({ skip }) => {
if (!erreichbar) return skip();
// Die Spalte ist auf 768 festgelegt. Ein Modellwechsel mit anderer Länge
// soll hier auffallen und nicht als stiller Datenmüll enden.
await expect(
system.$executeRawUnsafe(
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 90, 'Falsch', '[1,2,3]'::vector, now())`,
organisationId,
seiteId,
),
).rejects.toThrow();
});
it("nimmt einen Abschnitt auch ohne Einbettung an", async ({ skip }) => {
if (!erreichbar) return skip();
// Ohne Sprachmodell entstehen keine Vektoren — die Seite muss trotzdem
// gespeichert und über die Wortsuche auffindbar sein.
const abschnitt = await system.wikiabschnitt.create({
data: { organisationId, seiteId, reihenfolge: 50, text: "Ohne Vektor" },
});
expect(abschnitt.id).toBeTruthy();
});
});
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der neuen Tabellen", () => {
it("liefert ohne gesetzten Mandanten nichts", async ({ skip }) => {
if (!erreichbar) return skip();
const [vorgaenge, seiten, abschnitte] = await Promise.all([
app.kiVorgang.findMany({ where: { organisationId } }),
app.wikiseite.findMany({ where: { organisationId } }),
app.wikiabschnitt.findMany({ where: { organisationId } }),
]);
expect(vorgaenge).toHaveLength(0);
expect(seiten).toHaveLength(0);
expect(abschnitte).toHaveLength(0);
});
it("liefert mit gesetztem Mandanten die eigenen Daten", async ({ skip }) => {
if (!erreichbar) return skip();
const seiten = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.wikiseite.findMany({ where: { organisationId } });
});
expect(seiten.length).toBeGreaterThan(0);
});
it("verbirgt die Abschnitte einer fremden Wehr auch bei der Vektorsuche", async ({ skip }) => {
if (!erreichbar) return skip();
const fremde = await system.organisation.upsert({
where: { slug: "test-ki-fremde" },
update: {},
create: { slug: "test-ki-fremde", name: "Fremde Wehr", kurzname: "FW" },
});
const fremdeSeite = await system.wikiseite.create({
data: {
organisationId: fremde.id,
slug: "fremd",
titel: "Fremd",
inhalt: "Geheim.",
},
});
await system.$executeRawUnsafe(
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 0, 'Fremder Abschnitt', $3::vector, now())`,
fremde.id,
fremdeSeite.id,
vektor(1, 0, 0),
);
// Dieselbe Suche wie oben, aber im Kontext der eigenen Wehr: der fremde
// Abschnitt darf nicht auftauchen, obwohl sein Vektor perfekt passt.
const treffer = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
return tx.$queryRawUnsafe<Array<{ text: string }>>(
`SELECT "text" FROM "wikiabschnitt"
WHERE "einbettung" IS NOT NULL
ORDER BY "einbettung" <=> $1::vector
LIMIT 10`,
vektor(1, 0, 0),
);
});
expect(treffer.map((zeile) => zeile.text)).not.toContain("Fremder Abschnitt");
await system.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
await tx.organisation.deleteMany({ where: { slug: "test-ki-fremde" } });
});
});
});
describe.runIf(process.env.DATABASE_URL)("Belegentwürfe", () => {
it("weist einen negativen Betrag ab", async ({ skip }) => {
if (!erreichbar) return skip();
const datei = await system.datei.create({
data: {
organisationId,
bucket: "florian",
objektSchluessel: `test/${Date.now()}-beleg.jpg`,
dateiname: "beleg.jpg",
mimeTyp: "image/jpeg",
groesseBytes: 1000,
},
});
await expect(
system.belegentwurf.create({
data: { organisationId, fotoDateiId: datei.id, betrag: -5 },
}),
).rejects.toThrow(/betrag_positiv/);
});
it("weist einen unmöglichen Steuersatz ab", async ({ skip }) => {
if (!erreichbar) return skip();
const datei = await system.datei.create({
data: {
organisationId,
bucket: "florian",
objektSchluessel: `test/${Date.now()}-beleg2.jpg`,
dateiname: "beleg2.jpg",
mimeTyp: "image/jpeg",
groesseBytes: 1000,
},
});
await expect(
system.belegentwurf.create({
data: { organisationId, fotoDateiId: datei.id, steuersatz: 150 },
}),
).rejects.toThrow(/steuersatz_plausibel/);
});
});