/** * PDF-Export: Struktur, Lokalisierung und Datenintegrität. * * Die Textebene wird mit pdfjs-dist (Legacy-Build, wie im ImageProcessor) * extrahiert – der schnellste Weg, zu prüfen, dass die Zahlen wirklich im * PDF stehen und die Lokalisierung sauber ist. */ import { describe, test, expect } from "./runner"; import { generateReceiptPdf } from "../../src/lib/export/pdfGenerator"; import { ProcessedReceipt } from "../../src/lib/schema/receipt"; function receipt(overrides: Partial = {}): ProcessedReceipt { return { merchant: { name: "REWE", address: null, taxId: "DE123456789", confidence: 0.97 }, date: { isoDate: "2026-08-12", time: null, confidence: 0.95 }, documentType: "KASSENBON", receiptNumber: "1", currency: "EUR", totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, tipAmount: null, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }], lineItems: [ { description: "Vollmilch 3,5%", quantity: 2, price: 2.38, unitPrice: 1.19, taxRate: 7 }, { description: "Bio-Baguette", quantity: 1, price: 1.79, unitPrice: 1.79, taxRate: 19 }, ], suggestedCategory: "Material & Einkauf", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null, }, id: "r1", imageHash: "h1", originalFileName: "r1.jpg", fileSizeBytes: 1024, createdAt: "2026-08-12T10:00:00.000Z", updatedAt: "2026-08-12T10:00:00.000Z", status: "ready", ...overrides, } as ProcessedReceipt; } /** Extrahiert alle Texte eines generierten PDFs (pdfjs-Legacy-Build, Node). */ async function pdfText(bytes: Uint8Array): Promise<{ text: string[]; pages: number }> { const mod: any = await import("pdfjs-dist/legacy/build/pdf.mjs"); const pdfjs = mod.default ?? mod; const task = pdfjs.getDocument({ // Kopie: pdfjs übernimmt den Puffer. data: new Uint8Array(bytes), isEvalSupported: false, useSystemFonts: false, disableFontFace: true, }); const doc: any = await task.promise; const text: string[] = []; try { for (let i = 1; i <= doc.numPages; i++) { const page: any = await doc.getPage(i); const content: any = await page.getTextContent(); for (const item of content.items ?? []) text.push(String(item.str ?? "")); page.cleanup(); } } finally { await task.destroy().catch(() => undefined); } return { text, pages: doc.numPages }; } const joined = (lines: string[]) => lines.join("\n"); describe("PDF-Export: Struktur", () => { test("P-1: erzeugt eine gültige PDF-Datei", async () => { const bytes = await generateReceiptPdf([receipt()]); expect(bytes[0]).toBe(0x25); // '%' expect(String.fromCharCode(bytes[1])).toBe("P"); expect(String.fromCharCode(bytes[2])).toBe("D"); expect(String.fromCharCode(bytes[3])).toBe("F"); expect(bytes.length).toBeGreaterThan(1000); }); test("P-2: leerer Datensatz erzeugt eine gültige, lesbare PDF", async () => { const bytes = await generateReceiptPdf([]); const { text } = await pdfText(bytes); expect(joined(text)).toContain("BELEGE EXPORT"); expect(bytes[0]).toBe(0x25); }); test("P-3: die Kartenzahl wächst mit der Belegzahl (Seitenumbruch)", async () => { const one = await generateReceiptPdf([receipt()]); const many = await generateReceiptPdf( Array.from({ length: 14 }, (_, i) => receipt({ id: `r${i}`, totalAmount: { value: 31.8 + i, confidence: 0.98 } }) ) ); const { pages: pOne } = await pdfText(one); const { pages: pMany } = await pdfText(many); expect(pMany).toBeGreaterThanOrEqual(pOne); }); }); describe("PDF-Export: Datenintegrität", () => { test("P-4: Händler, Beträge und Positionen stehen im Dokument", async () => { const { text } = await pdfText(await generateReceiptPdf([receipt()])); const all = joined(text); expect(all).toContain("REWE"); expect(all).toContain("26,72 €"); // Netto expect(all).toContain("5,08 €"); // MwSt 19 % expect(all).toContain("31,80 €"); // Brutto expect(all).toContain("Vollmilch 3,5%"); expect(all).toContain("1,19 €"); // Einzelpreis expect(all).toContain("DE123456789"); // Steuernummer }); test("P-5: Trinkgeld erscheint als eigene Zeile inkl. Gesamt gezahlt", async () => { const r = receipt({ tipAmount: 5, totalAmount: { value: 31.8, confidence: 0.98 } }); const { text } = await pdfText(await generateReceiptPdf([r])); const all = joined(text); expect(all).toContain("Trinkgeld"); expect(all).toContain("5,00 €"); expect(all).toContain("Gesamt gezahlt"); expect(all).toContain("36,80 €"); }); test("P-6: ungeprüfter Beleg erhält Status Prüfen (n)", async () => { const r = receipt({ validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, reviewField: "totalAmount", reviewReason: "x", issues: [{ field: "totalAmount", severity: "error", message: "x" }], }, }); const { text } = await pdfText(await generateReceiptPdf([r])); expect(joined(text)).toContain("Prüfen (1)"); }); test("P-7: Bewirtungsangaben werden übernommen", async () => { const r = receipt({ documentType: "BEWIRTUNGSBELEG", suggestedCategory: "Bewirtung", hospitality: { occasion: "Kundenbesuch", participants: "Max Mustermann" }, }); const { text } = await pdfText(await generateReceiptPdf([r])); const all = joined(text); expect(all).toContain("Kundenbesuch"); expect(all).toContain("Max Mustermann"); }); test("P-8: gemischte Währungen werden nicht zu einer Summe vermischt", async () => { const eur = receipt({ id: "a", currency: "EUR", totalAmount: { value: 10, confidence: 0.98 } }); const usd = receipt({ id: "b", currency: "USD", totalAmount: { value: 5, confidence: 0.98 } }); const { text } = await pdfText(await generateReceiptPdf([eur, usd])); const all = joined(text); expect(all).toContain("mehrere Währungen"); expect(all).toContain("EUR: 10,00 €"); expect(all).toContain("USD: 5,00 USD"); }); test("P-9: unicode-gefährliche Zeichen werden entschärft statt zu brechen", async () => { const r = receipt({ merchant: { name: "CAFÉ ✓ BERLIN ☕", address: null, taxId: null, confidence: 0.9 }, }); const bytes = await generateReceiptPdf([r]); const { text } = await pdfText(bytes); expect(joined(text)).toContain("CAFÉ"); expect(joined(text)).toContain("BERLIN"); }); }); describe("PDF-Export: Lokalisierung", () => { test("P-10: Standard bleibt Deutsch", async () => { const { text } = await pdfText(await generateReceiptPdf([receipt()])); expect(joined(text)).toContain("BELEGE EXPORT"); expect(joined(text)).toContain("Netto"); expect(joined(text)).toContain("Brutto Gesamt"); }); test("P-11: locale 'en' übersetzt Titel und Beschriftungen", async () => { const { text } = await pdfText(await generateReceiptPdf([receipt()], { locale: "en" })); const all = joined(text); expect(all).toContain("RECEIPT EXPORT"); expect(all).toContain("Net"); expect(all).toContain("Gross total"); expect(all).toContain("VAT 19%"); }); test("P-12: im englischen Export bleibt kein deutsches Label stehen", async () => { const { text } = await pdfText(await generateReceiptPdf([receipt()], { locale: "en" })); const german = ["BELEGE EXPORT", "Netto", "Brutto Gesamt", "MwSt gesamt", "Trinkgeld", "Prüfen"]; const all = joined(text); for (const g of german) expect(all).not.toContain(g); }); test("P-13: unbekannte Sprache fällt auf Deutsch zurück", async () => { const bytes = await generateReceiptPdf( [receipt()], { locale: "fr" as unknown as "de" } ); const { text } = await pdfText(bytes); expect(joined(text)).toContain("BELEGE EXPORT"); }); test("P-14: Zeitraum erscheint im Untertitel", async () => { const { text } = await pdfText( await generateReceiptPdf([receipt()], { dateFrom: "2026-01-01", dateTo: "2026-12-31" }) ); expect(joined(text)).toContain("Zeitraum"); expect(joined(text)).toContain("01.01.2026"); }); });