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; const seite = await system.wikiseite.create({ data: { 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 = {}) { 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>( `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>( `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/); }); });