Files
AfiS-Aktensystem/packages/db/tests/regeln.test.ts
T
HarlekinandClaude Opus 5 d65cc95eb2 Phase 4 abgeschlossen: Personalakten, Dokumente, Beteiligte, Verknüpfungen
Die Dokumentebene aus docs/13 ist gebaut, additiv: Document, DocumentSection
und DocumentSectionVersion neben Case, CaseSection und CaseEntry. Ein Dokument
hängt an einer Fallakte, an einer Personalakte oder an nichts, höchstens an
einem — gesichert durch eine CHECK-Bedingung. Es gibt kein DELETE; eine
Zurücknahme lässt einen Grabstein stehen, der auch für den Admin zu ist.

Eine neue Dokumentart ist damit ein Datensatz, kein Deployment. Im Seed stehen
vier: vermerk, vorfallsbericht, sonderarbeitsvertrag (mehrseitiger Vertragstext
mit Platzhaltern und Unterschriftenzeilen) und bussgeld.

Zwei Umbenennungen, beide von Hand migriert, damit die vorhandenen Zeilen und
Zählerstände stehen bleiben:
- CaseTemplate -> DocumentTemplate, plus uses[] und layout
- CaseCounter -> NumberSeries, ein Nummernkreis je Präfix für Aktenzeichen
  und Dokumentnummern

Die Regeln für Abschnitte stehen jetzt einmal in lib/sections/render.ts und
gelten für Fallakten und Dokumente gleichermaßen. Die Frage aus docs/13, ob
CaseSection langfristig in die Dokumentebene wandert, ist damit verneint und
begründet.

Rückverweise auf der Personalakte laufen durch visibleCaseWhere, die Zahl
daneben entsteht aus dem gefilterten Ergebnis. Auch die Rollengruppe
verschwindet mit, sonst verriete "Betroffener" ohne Eintrag die Ermittlung.
Die eigene Personalakte ist keine Ausnahme von der Freigabestufe.

Vier neue Rechte: document.create, .edit, .finalize, .retract. Das letzte
steht bei den zurückgehaltenen, es greift in Geschriebenes ein.

91 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Größte Route 118 kB
gegen das Budget von 120 kB — die Luft wird knapp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:15:16 +02:00

240 lines
8.2 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createPrismaClient, type PrismaClient } from '../src/index.js'
/**
* Prüft die harten Regeln aus CLAUDE.md dort, wo sie wirklich gelten: in der
* Datenbank. Die Anwendung kann man umgehen, diese Regeln nicht.
*
* Läuft gegen eine echte Postgres-Instanz. Ohne DATABASE_URL wird übersprungen.
*/
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
const suite = url ? describe : describe.skip
let db: PrismaClient
const marke = `test-${Date.now()}`
suite('Datenbankregeln', () => {
let caseId: string
let entryId: string
beforeAll(async () => {
db = createPrismaClient(url)
const template = await db.documentTemplate.create({
data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] },
})
const user = await db.user.create({
data: { id: `${marke}-nutzer`, username: 'pruef', displayName: 'Prüfer' },
})
const fall = await db.case.create({
data: {
caseNumber: `AfiS-9999-${String(Date.now()).slice(-4)}`,
title: 'Prüffall',
templateId: template.id,
templateKey: template.key,
createdById: user.id,
classification: 1,
},
})
caseId = fall.id
const eintrag = await db.caseEntry.create({
data: {
id: `${marke}-eintrag`,
caseId,
type: 'NOTE',
body: 'Ursprünglicher Text',
authorId: user.id,
minClearance: 0,
},
})
entryId = eintrag.id
})
afterAll(async () => {
// CaseEntry lässt sich nicht löschen, deshalb bleibt der Prüffall stehen.
// Aufgeräumt wird, was aufräumbar ist.
await db.caseSection.deleteMany({ where: { caseId } }).catch(() => undefined)
await db.$disconnect()
})
it('lässt einen Verlaufseintrag nicht löschen, auch nicht über rohes SQL', async () => {
await db.$executeRawUnsafe(`DELETE FROM "CaseEntry" WHERE id = $1`, entryId)
const danach = await db.caseEntry.findUnique({ where: { id: entryId } })
expect(danach).not.toBeNull()
expect(danach?.body).toBe('Ursprünglicher Text')
})
it('lässt Autor und Entstehungszeit eines Eintrags nicht ändern', async () => {
await expect(
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "authorId" = 'jemand-anders' WHERE id = $1`, entryId),
).rejects.toThrow(/unveränderlich/)
await expect(
db.$executeRawUnsafe(`UPDATE "CaseEntry" SET "createdAt" = now() WHERE id = $1`, entryId),
).rejects.toThrow(/unveränderlich/)
})
it('erlaubt an einer Eintragsversion nur das Anheben von minClearance', async () => {
const version = await db.caseEntryVersion.create({
data: { entryId, version: 1, body: 'Vorheriger Stand', minClearance: 0, editedById: `${marke}-nutzer` },
})
// Umklassifizierung wirkt rückwirkend: das muss durchgehen.
await db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 4 } })
const angehoben = await db.caseEntryVersion.findUnique({ where: { id: version.id } })
expect(angehoben?.minClearance).toBe(4)
// Absenken nicht, sonst leckt der alte Text über die Historie.
await expect(
db.caseEntryVersion.update({ where: { id: version.id }, data: { minClearance: 1 } }),
).rejects.toThrow(/nur angehoben/)
// Der Text bleibt unantastbar.
await expect(
db.caseEntryVersion.update({ where: { id: version.id }, data: { body: 'umgeschrieben' } }),
).rejects.toThrow(/anhängend/)
// Und gelöscht wird sie auch nicht.
await db.$executeRawUnsafe(`DELETE FROM "CaseEntryVersion" WHERE id = $1`, version.id)
expect(await db.caseEntryVersion.findUnique({ where: { id: version.id } })).not.toBeNull()
})
it('lässt einen Protokolleintrag weder ändern noch löschen', async () => {
const eintrag = await db.auditLog.create({
data: {
actorId: `${marke}-nutzer`,
action: 'case.read',
targetType: 'Case',
targetId: caseId,
clearanceAtTime: 1,
},
})
await db.$executeRawUnsafe(`DELETE FROM "AuditLog" WHERE id = ${eintrag.id}`)
await db.$executeRawUnsafe(`UPDATE "AuditLog" SET action = 'gefälscht' WHERE id = ${eintrag.id}`)
const danach = await db.auditLog.findUnique({ where: { id: eintrag.id } })
expect(danach?.action).toBe('case.read')
})
it('weist Freigabestufen ausserhalb von 0 bis 5 ab', async () => {
await expect(
db.$executeRawUnsafe(`UPDATE "Case" SET classification = 9 WHERE id = $1`, caseId),
).rejects.toThrow()
})
it('verlangt für einen Beteiligten mindestens eine Zuordnung', async () => {
await expect(
db.$executeRawUnsafe(
`INSERT INTO "CaseParticipant" (id, "caseId", role) VALUES ('x-${marke}', $1, 'WITNESS')`,
caseId,
),
).rejects.toThrow()
})
it('lässt eine Chatnachricht nicht zugleich an Akte und Kanal hängen', async () => {
const kanal = await db.channel.create({
data: { key: `${marke}-kanal`, name: 'Prüfkanal', createdById: `${marke}-nutzer` },
})
await expect(
db.chatMessage.create({
data: {
id: `${marke}-nachricht`,
caseId,
channelId: kanal.id,
authorId: `${marke}-nutzer`,
body: 'gehört nirgends hin',
},
}),
).rejects.toThrow()
})
it('lässt ein Dokument nicht löschen', async () => {
const doc = await db.document.create({
data: {
documentNumber: `PRF-9999-${String(Date.now()).slice(-6)}`,
title: 'Prüfdokument',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
createdById: `${marke}-nutzer`,
},
})
// Die Regel schluckt das DELETE, statt zu werfen: so verhält sich
// DO INSTEAD NOTHING. Entscheidend ist, dass die Zeile stehen bleibt.
await db.document.deleteMany({ where: { id: doc.id } })
expect(await db.document.findUnique({ where: { id: doc.id } })).not.toBeNull()
})
it('lässt ein Dokument nicht an zwei Besitzern zugleich hängen', async () => {
const person = await db.personnelFile.create({
data: { displayName: `${marke} Person`, classification: 1 },
})
await expect(
db.document.create({
data: {
documentNumber: `PRF-9998-${String(Date.now()).slice(-6)}`,
title: 'Zwei Besitzer',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
personnelFileId: person.id,
createdById: `${marke}-nutzer`,
},
}),
).rejects.toThrow()
})
it('lässt den Besitzer eines Dokuments nicht nachträglich wechseln', async () => {
const doc = await db.document.create({
data: {
documentNumber: `PRF-9997-${String(Date.now()).slice(-6)}`,
title: 'Unverrückbar',
templateId: (await db.documentTemplate.findFirstOrThrow({
where: { key: `${marke}-vorlage` },
})).id,
templateKey: `${marke}-vorlage`,
templateVersion: 1,
caseId,
createdById: `${marke}-nutzer`,
},
})
// Ein Vertrag, der nachträglich an einer anderen Person hängt, wäre eine
// Fälschung. Der Trigger lässt das nicht zu.
await expect(
db.document.update({ where: { id: doc.id }, data: { caseId: null } }),
).rejects.toThrow()
})
it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => {
await db.caseEntry.create({
data: {
id: `${marke}-geschwaerzt`,
caseId,
type: 'NOTE',
body: 'Offener Teil [[c4]]Zeugenname Perreira[[/c4]] Schluss',
authorId: `${marke}-nutzer`,
minClearance: 0,
},
})
const [treffer] = await db.$queryRawUnsafe<{ offen: boolean; geheim: boolean }[]>(
`SELECT "searchVector" @@ websearch_to_tsquery('german', 'Offener') AS offen,
"searchVector" @@ websearch_to_tsquery('german', 'Perreira') AS geheim
FROM "Case" WHERE id = $1`,
caseId,
)
expect(treffer?.offen).toBe(true)
expect(treffer?.geheim).toBe(false)
})
})