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.caseTemplate.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('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) }) })