Files
FireAdministration/tests/unit/ki-datenbank.test.ts
T
florian.wilde 3b20abcb23
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
Version 1.0.0 — Freigabe für den Echtbetrieb (#17)
2026-09-06 20:08:16 +00: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/);
});
});