Phase 3, erster Teil: Vorlagen-Bausteine und Schwärzung
Entschieden und festgehalten: jede Akte trägt Kurzvermerke und Dokumente, die Fallakte zusätzlich ihre Abschnitte. Die Trennung aus docs/05 bleibt damit bestehen. Eine Vorlage ist jetzt eine geordnete Liste von Bausteinen. Manche nehmen eine Eingabe entgegen, andere liefern festen Text mit Platzhaltern. Damit tragen dieselbe Struktur ein Vorfallsbericht, der fast nur aus Eingaben besteht, und ein Sonderarbeitsvertrag, der fast nur aus Paragraphen und Unterschriftenzeilen besteht. Eine neue Dokumentart bleibt ein Datensatz, kein Deployment. Der Schwärzungs-Parser gibt Segmente zurück, nicht den vollen Text mit einem Sichtbarkeits-Flag: was der Client ausblenden müsste, hat er dann schon. Die Balkenlänge wird auf ein Raster von acht Zeichen gerundet, sonst verrät sie, wie lang der geschwärzte Name war. Ein Test vergleicht den TypeScript-Parser gegen die SQL-Funktion strip_redactions aus der Migration. Laufen die beiden auseinander, findet die Suche einen Begriff, den die Ansicht verbirgt — und der Treffer allein verrät ihn dann.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createPrismaClient, type PrismaClient } from '@afis/db'
|
||||
import { stripRedacted } from '@/lib/redaction'
|
||||
|
||||
/**
|
||||
* Die Schwärzung wird an zwei Stellen entfernt: in TypeScript beim Rendern und
|
||||
* in SQL, bevor der Suchvektor gefüllt wird. Laufen die beiden auseinander,
|
||||
* findet die Suche einen Begriff, den die Ansicht verbirgt — und der Treffer
|
||||
* allein verrät ihn dann.
|
||||
*/
|
||||
const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL
|
||||
const suite = url ? describe : describe.skip
|
||||
|
||||
let db: PrismaClient
|
||||
|
||||
const PROBEN = [
|
||||
'Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.',
|
||||
'[[c2]]zwei[[/c2]] und [[c5]]fünf[[/c5]] und [[c0]]offen[[/c0]]',
|
||||
'ganz ohne Auszeichnung',
|
||||
'mehrzeilig [[c3]]Zeile eins\nZeile zwei[[/c3]] Ende',
|
||||
'unvollständige [[c3]]Auszeichnung ohne Ende',
|
||||
]
|
||||
|
||||
suite('Schwärzung in TypeScript und SQL', () => {
|
||||
beforeAll(() => {
|
||||
db = createPrismaClient(url)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
it('entfernt in beiden Sprachen dieselben Stellen', async () => {
|
||||
for (const probe of PROBEN) {
|
||||
const [row] = await db.$queryRaw<{ ergebnis: string }[]>`
|
||||
SELECT strip_redactions(${probe}) AS ergebnis
|
||||
`
|
||||
const ausSql = row?.ergebnis ?? ''
|
||||
const ausTs = stripRedacted(probe)
|
||||
|
||||
// Auf Wortebene vergleichen: SQL ersetzt durch ein Leerzeichen, der
|
||||
// Parser ebenso, aber die umliegenden Leerzeichen können abweichen.
|
||||
expect(ausSql.split(/\s+/).filter(Boolean)).toEqual(
|
||||
ausTs.split(/\s+/).filter(Boolean),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('lässt geschwärzte Begriffe in keiner der beiden Fassungen stehen', async () => {
|
||||
const probe = PROBEN[0]!
|
||||
const [row] = await db.$queryRaw<{ ergebnis: string }[]>`
|
||||
SELECT strip_redactions(${probe}) AS ergebnis
|
||||
`
|
||||
expect(row?.ergebnis).not.toContain('Perreira')
|
||||
expect(stripRedacted(probe)).not.toContain('Perreira')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
barLength,
|
||||
highestRedactionLevel,
|
||||
markRedaction,
|
||||
redactForLevel,
|
||||
stripMarkup,
|
||||
stripRedacted,
|
||||
} from '@/lib/redaction'
|
||||
|
||||
const BEISPIEL = 'Der Hinweis kam von [[c4]]Wachmann Perreira, Sektor B[[/c4]] am 27.08.'
|
||||
|
||||
describe('Schwärzung im Freitext', () => {
|
||||
it('gibt den Inhalt frei, wenn die Stufe reicht', () => {
|
||||
const segmente = redactForLevel(BEISPIEL, 4)
|
||||
expect(segmente.every((s) => s.kind === 'text')).toBe(true)
|
||||
expect(segmente.map((s) => (s.kind === 'text' ? s.text : '')).join('')).toContain(
|
||||
'Wachmann Perreira',
|
||||
)
|
||||
})
|
||||
|
||||
it('schickt den Inhalt nicht mit, wenn die Stufe nicht reicht', () => {
|
||||
const segmente = redactForLevel(BEISPIEL, 1)
|
||||
const alsText = JSON.stringify(segmente)
|
||||
// Das ist der Kern: der echte Wert verlässt den Server nicht.
|
||||
expect(alsText).not.toContain('Perreira')
|
||||
expect(alsText).not.toContain('Sektor B')
|
||||
|
||||
const geschwaerzt = segmente.find((s) => s.kind === 'redacted')
|
||||
expect(geschwaerzt).toMatchObject({ requiredLevel: 4 })
|
||||
})
|
||||
|
||||
it('rundet die Balkenlänge auf ein Raster, damit sie nichts verrät', () => {
|
||||
// Zwei verschieden lange Namen dürfen nicht zu verschieden langen Balken
|
||||
// führen, sonst lässt sich aus der Länge raten.
|
||||
const kurz = redactForLevel('a [[c3]]Kim[[/c3]] b', 0)
|
||||
const laenger = redactForLevel('a [[c3]]Kim Lee[[/c3]] b', 0)
|
||||
const balken = (s: ReturnType<typeof redactForLevel>) =>
|
||||
s.find((x) => x.kind === 'redacted') as { blockLength: number }
|
||||
|
||||
expect(balken(kurz).blockLength).toBe(balken(laenger).blockLength)
|
||||
expect(barLength(1)).toBe(8)
|
||||
expect(barLength(8)).toBe(8)
|
||||
expect(barLength(9)).toBe(16)
|
||||
})
|
||||
|
||||
it('behält den Text um die Schwärzung herum', () => {
|
||||
const segmente = redactForLevel(BEISPIEL, 0)
|
||||
const sichtbar = segmente.map((s) => (s.kind === 'text' ? s.text : '')).join('')
|
||||
expect(sichtbar).toContain('Der Hinweis kam von')
|
||||
expect(sichtbar).toContain('am 27.08.')
|
||||
})
|
||||
|
||||
it('kommt mit mehreren und verschachtelt wirkenden Auszeichnungen zurecht', () => {
|
||||
const text = '[[c2]]zwei[[/c2]] und [[c5]]fünf[[/c5]] und [[c0]]offen[[/c0]]'
|
||||
const segmente = redactForLevel(text, 2)
|
||||
const stufen = segmente.filter((s) => s.kind === 'redacted').map((s) => s.requiredLevel)
|
||||
expect(stufen).toEqual([5])
|
||||
expect(JSON.stringify(segmente)).toContain('zwei')
|
||||
expect(JSON.stringify(segmente)).toContain('offen')
|
||||
expect(JSON.stringify(segmente)).not.toContain('fünf')
|
||||
})
|
||||
|
||||
it('nennt die höchste ausgezeichnete Stufe, ohne den Text preiszugeben', () => {
|
||||
expect(highestRedactionLevel(BEISPIEL)).toBe(4)
|
||||
expect(highestRedactionLevel('ohne Auszeichnung')).toBe(0)
|
||||
})
|
||||
|
||||
it('entfernt die Auszeichnung, behält aber den Inhalt', () => {
|
||||
expect(stripMarkup(BEISPIEL)).toBe(
|
||||
'Der Hinweis kam von Wachmann Perreira, Sektor B am 27.08.',
|
||||
)
|
||||
})
|
||||
|
||||
it('entfernt ausgezeichnete Bereiche für den Suchvektor', () => {
|
||||
// Muss dasselbe leisten wie strip_redactions in der Migration.
|
||||
const fuerIndex = stripRedacted(BEISPIEL)
|
||||
expect(fuerIndex).not.toContain('Perreira')
|
||||
expect(fuerIndex).toContain('Der Hinweis kam von')
|
||||
})
|
||||
|
||||
it('zeichnet einen markierten Bereich aus', () => {
|
||||
const quelle = 'Zeuge Meier war dabei'
|
||||
const markiert = markRedaction(quelle, 6, 11, 3)
|
||||
expect(markiert).toBe('Zeuge [[c3]]Meier[[/c3]] war dabei')
|
||||
expect(redactForLevel(markiert, 1).some((s) => s.kind === 'redacted')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user