import { describe, expect, it } from 'vitest' import { endungPasst, inhaltBeschreiben, typErkennen, } from '@/lib/speicher/dateityp' import { betreuteIdAusSchluessel, schluesselErzeugen, schluesselGueltig, UngueltigerSchluessel, zugriffTokenErzeugen, zugriffTokenPruefen, } from '@/lib/speicher/schluessel' import { aktenzeichenErkennen, alleAktenzeichen, absenderErkennen, datenFinden, dokumentartErkennen, erkennungsvorschlaege, fristenErkennen, schreibdatumErkennen, } from '@/lib/dokumente/erkennung' import { suchanfrageAufbereiten } from '@/lib/dokumente/suchbegriff' /** * Diese Tests beschreiben, was fachlich gelten soll - nicht, welche * Funktion aufgerufen wird. Sie brauchen weder Datenbank noch Netz. */ // ----------------------------------------------------------------------------- // Hilfen: kleine, gueltige Dateien im Speicher bauen // ----------------------------------------------------------------------------- function pdfDatei(): Buffer { return Buffer.concat([ Buffer.from('%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n', 'latin1'), Buffer.from('trailer\n<< /Root 1 0 R >>\n%%EOF\n', 'latin1'), ]) } function jpegDatei(): Buffer { return Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64, 0x11)]) } function pngDatei(): Buffer { return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(64, 0x22), ]) } function webpDatei(): Buffer { const kopf = Buffer.alloc(16) kopf.write('RIFF', 0, 'ascii') kopf.writeUInt32LE(64, 4) kopf.write('WEBPVP8 ', 8, 'ascii') return Buffer.concat([kopf, Buffer.alloc(48, 0x33)]) } function tiffDatei(): Buffer { return Buffer.concat([Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.alloc(64, 0x44)]) } function heicDatei(): Buffer { const kopf = Buffer.alloc(24) kopf.writeUInt32BE(24, 0) kopf.write('ftypheic', 4, 'ascii') kopf.write('mif1heic', 12, 'ascii') return Buffer.concat([kopf, Buffer.alloc(48, 0x55)]) } /** Eine HTML-Seite, die sich als PDF ausgibt. */ function getarnteHtmlDatei(): Buffer { return Buffer.from( '\nrechnung.pdf' + '', 'utf8', ) } // ----------------------------------------------------------------------------- // Dateityp // ----------------------------------------------------------------------------- describe('Der Dateityp wird am Inhalt entschieden, nicht am Namen', () => { it('erkennt die sechs erlaubten Formate', () => { expect(typErkennen(pdfDatei())?.mimeTyp).toBe('application/pdf') expect(typErkennen(jpegDatei())?.mimeTyp).toBe('image/jpeg') expect(typErkennen(pngDatei())?.mimeTyp).toBe('image/png') expect(typErkennen(webpDatei())?.mimeTyp).toBe('image/webp') expect(typErkennen(tiffDatei())?.mimeTyp).toBe('image/tiff') expect(typErkennen(heicDatei())?.mimeTyp).toBe('image/heic') }) it('lehnt eine HTML-Seite ab, die sich als PDF ausgibt', () => { const getarnt = getarnteHtmlDatei() expect(typErkennen(getarnt)).toBeNull() expect(inhaltBeschreiben(getarnt)).toBe('eine HTML-Seite') }) it('lehnt ein PDF ab, dem Fremdinhalt vorangestellt wurde', () => { // Der klassische Polyglot: erst HTML, dann irgendwo %PDF. const polyglot = Buffer.concat([Buffer.from('x\n'), pdfDatei()]) expect(typErkennen(polyglot)).toBeNull() }) it('lehnt Office-Dateien, Archive und Programme ab', () => { const zip = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.alloc(32)]) const exe = Buffer.concat([Buffer.from([0x4d, 0x5a]), Buffer.alloc(32)]) expect(typErkennen(zip)).toBeNull() expect(typErkennen(exe)).toBeNull() expect(inhaltBeschreiben(zip)).toContain('ZIP') }) it('lehnt zu kurze und leere Dateien ab', () => { expect(typErkennen(Buffer.alloc(0))).toBeNull() expect(typErkennen(Buffer.from('%PDF'))).toBeNull() expect(inhaltBeschreiben(Buffer.alloc(0))).toBe('eine leere Datei') }) it('merkt, wenn die Endung nicht zum Inhalt passt', () => { const jpeg = typErkennen(jpegDatei())! expect(endungPasst('foto.jpeg', jpeg)).toBe(true) expect(endungPasst('foto.jpg', jpeg)).toBe(true) expect(endungPasst('rechnung.pdf', jpeg)).toBe(false) }) }) // ----------------------------------------------------------------------------- // Ablageschluessel // ----------------------------------------------------------------------------- describe('Der Ablageschlüssel wird erzeugt und lässt sich nicht ausbrechen', () => { it('setzt die betreuteId an die zweite Stelle', () => { const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') expect(schluessel).toMatch(/^betreute\/clx123abc\/dokumente\/[0-9a-f-]{36}\.pdf$/) expect(betreuteIdAusSchluessel(schluessel)).toBe('clx123abc') }) it('erzeugt für dieselbe Datei nie zweimal denselben Schlüssel', () => { const a = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') const b = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') expect(a).not.toBe(b) }) it('weist eine Kennung mit Pfadanteilen zurück', () => { expect(() => schluesselErzeugen('../../etc', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) expect(() => schluesselErzeugen('a/b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) expect(() => schluesselErzeugen('a.b', 'dokumente', 'pdf')).toThrow(UngueltigerSchluessel) }) it('erkennt von Hand gebastelte Schlüssel als ungültig', () => { expect(schluesselGueltig('betreute/clx1/dokumente/../../../etc/passwd')).toBe(false) expect(schluesselGueltig('betreute/clx1/dokumente/datei.pdf')).toBe(false) expect(schluesselGueltig('betreute/clx1/geheim/00000000-0000-0000-0000-000000000000.pdf')).toBe( false, ) expect(schluesselGueltig('/etc/passwd')).toBe(false) expect(schluesselGueltig('betreute\\clx1\\dokumente\\x.pdf')).toBe(false) expect(schluesselGueltig(null)).toBe(false) }) it('nimmt den Dateinamen des Nutzers nicht in den Pfad auf', () => { const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') expect(schluessel).not.toContain('rechnung') expect(schluessel.split('/')).toHaveLength(4) }) }) // ----------------------------------------------------------------------------- // Signierte Links // ----------------------------------------------------------------------------- describe('Signierte Dateilinks laufen ab und lassen sich nicht fälschen', () => { const schluessel = schluesselErzeugen('clx123abc', 'dokumente', 'pdf') const jetzt = new Date('2026-03-12T10:00:00.000Z') it('erkennt ein frisches Token an', () => { const token = zugriffTokenErzeugen(schluessel, 300, jetzt) const geprueft = zugriffTokenPruefen(token, jetzt) expect(geprueft.gueltig).toBe(true) if (geprueft.gueltig) expect(geprueft.schluessel).toBe(schluessel) }) it('weist ein abgelaufenes Token zurück', () => { const token = zugriffTokenErzeugen(schluessel, 300, jetzt) const spaeter = new Date(jetzt.getTime() + 301_000) const geprueft = zugriffTokenPruefen(token, spaeter) expect(geprueft.gueltig).toBe(false) if (!geprueft.gueltig) expect(geprueft.grund).toBe('abgelaufen') }) it('weist ein Token mit verlängertem Ablauf zurück', () => { const token = zugriffTokenErzeugen(schluessel, 60, jetzt) const [kodiert, ablauf, signatur] = token.split('.') const verlaengert = `${kodiert}.${Number(ablauf) + 86_400}.${signatur}` const geprueft = zugriffTokenPruefen(verlaengert, jetzt) expect(geprueft.gueltig).toBe(false) if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') }) it('weist ein Token zurück, in das ein fremder Schlüssel geschoben wurde', () => { const token = zugriffTokenErzeugen(schluessel, 300, jetzt) const fremd = schluesselErzeugen('clyFREMD99', 'dokumente', 'pdf') const [, ablauf, signatur] = token.split('.') const gebastelt = `${Buffer.from(fremd, 'utf8').toString('base64url')}.${ablauf}.${signatur}` const geprueft = zugriffTokenPruefen(gebastelt, jetzt) expect(geprueft.gueltig).toBe(false) if (!geprueft.gueltig) expect(geprueft.grund).toBe('signatur') }) it('weist Unsinn und leere Werte zurück', () => { for (const wert of ['', 'abc', 'a.b.c', null, undefined, 12]) { expect(zugriffTokenPruefen(wert as unknown, jetzt).gueltig).toBe(false) } }) }) // ----------------------------------------------------------------------------- // Datum und Aktenzeichen // ----------------------------------------------------------------------------- describe('Datumsangaben im Schreiben werden gelesen', () => { it('versteht 12.03.2026, 12. März 2026 und 2026-03-12', () => { expect(datenFinden('Bayreuth, 12.03.2026')[0]?.datum.toISOString()).toBe( '2026-03-12T00:00:00.000Z', ) expect(datenFinden('Bayreuth, 12. März 2026')[0]?.datum.toISOString()).toBe( '2026-03-12T00:00:00.000Z', ) expect(datenFinden('Stand 2026-03-12')[0]?.datum.toISOString()).toBe( '2026-03-12T00:00:00.000Z', ) }) it('ergänzt zweistellige Jahre sinnvoll', () => { expect(datenFinden('am 12.03.26')[0]?.datum.getUTCFullYear()).toBe(2026) expect(datenFinden('am 12.03.99')[0]?.datum.getUTCFullYear()).toBe(1999) }) it('erfindet keinen 31. Februar', () => { expect(datenFinden('am 31.02.2026')).toHaveLength(0) expect(datenFinden('am 45.13.2026')).toHaveLength(0) }) it('nimmt das ausdrücklich benannte Datum des Schreibens', () => { const text = [ 'Bezirk Oberfranken', 'Sozialverwaltung', '', 'Bayreuth, 05.03.2026', 'Ihr Schreiben vom 12.03.2026', ].join('\n') const vorschlag = schreibdatumErkennen(text, new Date('2026-06-01T00:00:00.000Z')) expect(vorschlag?.wert.toISOString()).toBe('2026-03-12T00:00:00.000Z') expect(vorschlag?.sicherheit).toBe('hoch') }) it('übernimmt kein Datum aus der Zukunft als Briefdatum', () => { const text = 'Zahlbar bis zum 30.12.2030' expect(schreibdatumErkennen(text, new Date('2026-03-12T00:00:00.000Z'))).toBeNull() }) }) describe('Aktenzeichen und Nummern werden erkannt', () => { it('liest das Aktenzeichen des Betreuungsgerichts', () => { const vorschlag = aktenzeichenErkennen('Amtsgericht Bayreuth\nXVII 123/25\n\nBeschluss') expect(vorschlag?.wert).toBe('XVII 123/25') }) it('liest ein beschriftetes Aktenzeichen', () => { expect(aktenzeichenErkennen('Az.: 4711/26\nBetreff: Hilfe zur Pflege')?.wert).toBe('4711/26') expect(aktenzeichenErkennen('Aktenzeichen: SG 12 SO 45/26\n')?.wert).toBe('SG 12 SO 45/26') }) it('liest Kunden- und Versicherungsnummern', () => { expect(aktenzeichenErkennen('Kd.-Nr. 998877\nRechnung')?.wert).toBe('998877') expect(aktenzeichenErkennen('Versicherungsschein-Nr. LV-4711/2\n')?.wert).toBe('LV-4711/2') }) it('hält eine reine Jahreszahl nicht für ein Aktenzeichen', () => { expect(aktenzeichenErkennen('Zeitraum 01/2025 bis 12/2025')).toBeNull() }) it('sammelt mehrere Nummern für die Anzeige', () => { const text = 'Az.: 4711/26\nKundennummer: 998877\n' expect(alleAktenzeichen(text)).toEqual(expect.arrayContaining(['4711/26', '998877'])) }) }) // ----------------------------------------------------------------------------- // Dokumentart und Absender // ----------------------------------------------------------------------------- describe('Die Art des Schreibens wird vorgeschlagen', () => { it('erkennt einen Widerspruchsbescheid als Bescheid', () => { const text = 'Widerspruchsbescheid\n\nIhr Antrag auf Hilfe zur Pflege wird abgelehnt.' expect(dokumentartErkennen(text)?.wert).toBe('BESCHEID') }) it('unterscheidet Mahnung von Rechnung', () => { expect(dokumentartErkennen('Mahnung\nOffener Rechnungsbetrag 120,00 EUR')?.wert).toBe('MAHNUNG') expect(dokumentartErkennen('Rechnung\nRechnungsnummer 4711\nZahlbetrag')?.wert).toBe('RECHNUNG') }) it('erkennt Beschluss, Grundbuchauszug und Kaufvertrag', () => { expect(dokumentartErkennen('Beschluss\nIm Namen des Volkes')?.wert).toBe('BESCHLUSS') expect(dokumentartErkennen('Grundbuchauszug\nGrundbuch von Bayreuth')?.wert).toBe( 'GRUNDBUCHAUSZUG', ) expect(dokumentartErkennen('Kaufvertrag über das Grundstück')?.wert).toBe('VERTRAG') }) it('gibt nichts vor, wenn nichts erkennbar ist', () => { expect(dokumentartErkennen('lorem ipsum dolor')).toBeNull() }) }) describe('Der Absender wird vorgeschlagen', () => { const brief = [ 'Bezirk Oberfranken', 'Sozialverwaltung', '95440 Bayreuth', '', 'Bayreuth, 12.03.2026', '', 'Sehr geehrte Frau Wilde,', ].join('\n') it('bevorzugt einen bereits bekannten Absender', () => { const vorschlag = absenderErkennen(brief, ['Bezirk Oberfranken, Sozialverwaltung']) expect(vorschlag?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') expect(vorschlag?.sicherheit).toBe('hoch') }) it('erkennt sonst die Behörde im Briefkopf', () => { const vorschlag = absenderErkennen(brief, []) expect(vorschlag?.wert).toBe('Bezirk Oberfranken') expect(vorschlag?.sicherheit).toBe('mittel') }) it('hält die Anrede nicht für den Absender', () => { const vorschlag = absenderErkennen('Sehr geehrte Frau Wilde,\n\nwir teilen Ihnen mit …', []) expect(vorschlag?.wert ?? '').not.toMatch(/Sehr geehrte/) }) }) // ----------------------------------------------------------------------------- // Fristen // ----------------------------------------------------------------------------- describe('Fristen im Text werden vorgeschlagen, nie angelegt', () => { const zugang = new Date(Date.UTC(2026, 2, 12)) // 12.03.2026 it('rechnet "innerhalb von vier Wochen" ab dem Zugangsdatum', () => { const text = 'Gegen diesen Bescheid können Sie innerhalb von vier Wochen nach Bekanntgabe ' + 'Widerspruch einlegen.' const [frist] = fristenErkennen(text, zugang) expect(frist).toBeDefined() expect(frist!.datum?.toISOString()).toBe('2026-04-09T00:00:00.000Z') expect(frist!.basis?.toISOString()).toBe('2026-03-12T00:00:00.000Z') expect(frist!.rechtsbehelf).toBe(true) expect(frist!.textstelle).toContain('vier Wochen') }) it('versteht "binnen eines Monats" und "einen Monat nach Bekanntgabe"', () => { const a = fristenErkennen('Widerspruch binnen eines Monats.', zugang)[0] expect(a?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') const b = fristenErkennen('Der Widerspruch ist einen Monat nach Bekanntgabe zu erheben.', zugang)[0] expect(b?.datum?.toISOString()).toBe('2026-04-12T00:00:00.000Z') }) it('übernimmt ein ausdrücklich genanntes Datum', () => { const [frist] = fristenErkennen('Wir bitten um Zahlung bis zum 30.04.2026.', zugang) expect(frist?.datum?.toISOString()).toBe('2026-04-30T00:00:00.000Z') expect(frist?.sicherheit).toBe('hoch') }) it('hält einen Zeitraum nicht für eine Frist', () => { expect(fristenErkennen('Abrechnung vom 01.01.2025 bis 31.12.2025', zugang)).toHaveLength(0) }) it('liefert ohne Zugangsdatum kein erfundenes Datum', () => { const [frist] = fristenErkennen('Bitte antworten Sie innerhalb von 14 Tagen.', null) expect(frist?.datum).toBeNull() expect(frist?.begruendung).toContain('Eingangsdatum') }) it('nennt bei jedem Vorschlag die Textstelle, auf die er sich stützt', () => { const fristen = fristenErkennen('Zahlung binnen zwei Wochen erbeten.', zugang) expect(fristen[0]?.textstelle.length).toBeGreaterThan(0) expect(fristen[0]?.begruendung).toContain('2 Wochen') }) }) // ----------------------------------------------------------------------------- // Alles zusammen // ----------------------------------------------------------------------------- describe('Ein abfotografierter Bescheid liefert einen brauchbaren Vorschlag', () => { const bescheid = [ 'Bezirk Oberfranken', 'Sozialverwaltung', '95440 Bayreuth', '', 'Bayreuth, 05.03.2026', 'Aktenzeichen: 4711/26', '', 'Betreff: Hilfe zur Pflege nach dem SGB XII', '', 'Widerspruchsbescheid', '', 'Sehr geehrte Frau Wilde,', 'Ihr Widerspruch wird zurückgewiesen. Gegen diesen Bescheid können Sie innerhalb', 'von vier Wochen nach Bekanntgabe Klage erheben.', ].join('\n') it('schlägt Absender, Datum, Aktenzeichen, Art und Frist vor', () => { const ergebnis = erkennungsvorschlaege(bescheid, { bekannteAbsender: ['Bezirk Oberfranken, Sozialverwaltung'], zugangAm: new Date(Date.UTC(2026, 2, 9)), heute: new Date(Date.UTC(2026, 2, 20)), }) expect(ergebnis.absender?.wert).toBe('Bezirk Oberfranken, Sozialverwaltung') expect(ergebnis.schreibenVom?.wert.toISOString()).toBe('2026-03-05T00:00:00.000Z') expect(ergebnis.aktenzeichen?.wert).toBe('4711/26') expect(ergebnis.art?.wert).toBe('BESCHEID') expect(ergebnis.titel).toBe('Hilfe zur Pflege nach dem SGB XII') expect(ergebnis.fristen[0]?.datum?.toISOString()).toBe('2026-04-06T00:00:00.000Z') expect(ergebnis.fristen[0]?.rechtsbehelf).toBe(true) }) it('liefert bei leerem Text keine erfundenen Werte', () => { const leer = erkennungsvorschlaege('', {}) expect(leer.absender).toBeNull() expect(leer.art).toBeNull() expect(leer.fristen).toEqual([]) }) }) // ----------------------------------------------------------------------------- // Suchanfrage // ----------------------------------------------------------------------------- describe('Die Suchanfrage wird für websearch_to_tsquery aufbereitet', () => { it('lässt die gewohnte Suchmaschinen-Schreibweise stehen', () => { expect(suchanfrageAufbereiten('"Hilfe zur Pflege" -Mahnung')).toBe('"Hilfe zur Pflege" -Mahnung') }) it('räumt Leerraum und Steuerzeichen weg', () => { expect(suchanfrageAufbereiten(' Bescheid \n Bezirk \t')).toBe('Bescheid Bezirk') }) it('entfernt ein einzelnes offenes Anführungszeichen', () => { expect(suchanfrageAufbereiten('"Hilfe zur Pflege')).toBe('Hilfe zur Pflege') }) it('gibt nichts zurück, wenn nichts Suchbares übrig bleibt', () => { expect(suchanfrageAufbereiten('')).toBeNull() expect(suchanfrageAufbereiten(' ')).toBeNull() expect(suchanfrageAufbereiten('a')).toBeNull() expect(suchanfrageAufbereiten(null)).toBeNull() }) it('begrenzt die Länge, damit niemand die Datenbank beschäftigt', () => { expect(suchanfrageAufbereiten('x'.repeat(5000))!.length).toBe(200) }) })