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.
240 lines
8.2 KiB
TypeScript
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)
|
|
})
|
|
})
|