446 lines
15 KiB
TypeScript
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/);
|
|
});
|
|
});
|