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>
216 lines
8.2 KiB
TypeScript
216 lines
8.2 KiB
TypeScript
/**
|
||
* 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");
|
||
});
|
||
}); |