Files
FireAdministration/tests/unit/ki-datenbank.test.ts
T
Florian WildeandClaude Opus 5 30faa6cb02
Pruefung / pruefung (pull_request) Canceled after 0s
Pruefung / oberflaeche (pull_request) Canceled after 0s
Version 1.0.0 — Freigabe fuer den Echtbetrieb
Kein neues Modul. Zwoelf Phasen sind fertig, und die Wehr kann ab jetzt
echte Daten hineingeben, ohne befuerchten zu muessen, sie beim naechsten
Update zu verlieren.

Neu dazu, weil beides zum Betrieb gehoert:

update.sh aktualisiert auf einem Linux-Server in einem Befehl. Der Ablauf
darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage zu
hinterlassen: erst nachsehen ob es etwas Neues gibt, dann sichern, dann
holen und bauen (schlaegt das fehl, laeuft die alte Fassung unveraendert
weiter), dann umschalten, dann pruefen ob die Anwendung antwortet. Tut sie
es nicht, stehen die Protokollzeilen da und die Befehle fuer den Rueckweg.

docs/KURZANLEITUNG.md ist der Einstieg fuer die Wehr - nach Rollen
sortiert, aufgabenorientiert statt nach Modulen, und bei jedem heiklen
Punkt steht das Warum dabei. Die vollstaendige Anleitung hat 63 Kapitel;
die liest niemand, bevor er das erste Geraet pruefen will.

Die Roadmap haelt den Stand JEDER Freigabevoraussetzung fest, auch der
offenen: Datenuebernahme entfaellt (die Wehr pflegt von Hand ein),
Sicherungskonzept erledigt, Loeschkonzept technisch vollstaendig aber
nicht abgenommen, Schulung steht aus, Atemschutzhinweis bewusst stehen
gelassen. Eine Freigabe, die abhakt was nicht geprueft wurde, ist keine.

Nebenbei die Ursache der Testflackerei gefunden, die sich durch mehrere
Phasen zog: zwei Datenbanktests schalteten beim Aufraeumen ihre Trigger
mit ALTER TABLE ... DISABLE TRIGGER ab - eine Schemaaenderung, die fuer
die ganze Datenbank gilt. Vitest faehrt die Dateien parallel, also pruefte
eine Datei gerade dass ein Trigger greift, waehrend eine andere ihn
abgeraeumt hatte. Jetzt sitzungslokal ueber session_replication_role.

Erstmals in diesem Projekt: fuenf Unit-Testlaeufe hintereinander 601 gruen
und ein E2E-Lauf mit 268 gruen ohne Fehlschlag und ohne Flackern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 18:09:26 +02:00

449 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)`;
// Trigger fuer das Aufraeumen stilllegen — sitzungslokal.
//
// NICHT ueber `ALTER TABLE ... DISABLE TRIGGER`: das ist eine
// Schemaänderung und gilt fuer alle. Vitest faehrt die Testdateien
// parallel, und waehrend hier abgeraeumt wurde, pruefte anderswo
// eine Datei, dass genau dieser Trigger greift — und sah keinen.
// `session_replication_role` wirkt nur in dieser Transaktion.
await tx.$executeRawUnsafe("SET LOCAL session_replication_role = 'replica'");
await tx.organisation.deleteMany({ where: { slug: SLUG } });
});
}
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/);
});
});