Files
scan-receipts/tests/e2e/export_pdf.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

216 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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> = {}): 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");
});
});