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

463 lines
17 KiB
TypeScript

/**
* Milestone 3 (R3) Challenger 2 Deep Adversarial Stress Suite
* Empirical Verification of Requirement R3:
* 1. Inline Cell Editing & Mathematical Recalculations (0 €, negative, corrupted strings, rapid sequential edits)
* 2. Bulk Export Generation (Selected only vs all, CSV injection sanitization, UTF-8 BOM, dual-sheet XLSX integrity)
* 3. Bulk Status Updates & Categorization & Deletion
* 4. Selection & Filtering Edge Cases & Invariants
*/
import { describe, test, it, expect, beforeEach } from "./runner";
import { ProcessedReceipt, ReceiptCategory, PaymentMethod, DocumentType } from "../../src/lib/schema/receipt";
import {
resolveReceiptStatusTier,
getStatusTierMeta,
ReceiptStatusTier,
} from "../../src/components/dashboard/StatusBadge";
import {
recalculateReceipt,
confirmReceiptReviewed,
receiptNeedsAttention,
} from "../../src/lib/ai/recalculate";
import {
parseAmountInput,
formatAmountInput,
formatMoney,
grossOf,
netOf,
totalTaxOf,
taxAmountForRate,
collectTaxRates,
} from "../../src/components/dashboard/receiptFormat";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
function createTestReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id: "challenger-rcpt-001",
imageHash: "hash-ch-123",
originalFileName: "sample_receipt.pdf",
fileSizeBytes: 150000,
previewUrl: "blob:http://localhost/sample.pdf",
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready",
documentType: "KASSENBON",
receiptNumber: "RE-9901",
currency: "EUR",
merchant: {
name: "Café Extrablatt GmbH",
address: "Alexanderplatz 1, 10178 Berlin",
taxId: "DE123456789",
confidence: 0.95,
},
date: {
isoDate: "2026-08-15",
time: "14:30",
confidence: 0.95,
},
totalAmount: {
value: 119.0,
confidence: 0.95,
},
netAmount: 100.0,
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 },
],
lineItems: [
{
description: "Espresso Doppio",
quantity: 2,
unitPrice: 3.5,
price: 7.0,
taxRate: 19,
},
{
description: "Frühstücksbuffet",
quantity: 4,
unitPrice: 28.0,
price: 112.0,
taxRate: 19,
},
],
suggestedCategory: "Bewirtung",
paymentMethod: "EC_KARTE",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
userConfirmed: false,
reviewField: "none",
reviewReason: null,
issues: [],
},
...overrides,
};
}
describe("CHALLENGER-M3: Inline Cell Editing & Numeric Parsing Stress", () => {
test("CH-M3.1: parseAmountInput parses varied valid and tricky numeric inputs", () => {
expect(parseAmountInput("119,00")).toBe(119.0);
expect(parseAmountInput("119.00")).toBe(119.0);
expect(parseAmountInput("1.234,56 €")).toBe(1234.56);
expect(parseAmountInput("1,234.56 EUR")).toBe(1234.56);
expect(parseAmountInput("0,00")).toBe(0.0);
expect(parseAmountInput("0")).toBe(0.0);
expect(parseAmountInput("0.05")).toBe(0.05);
expect(parseAmountInput("-50,00")).toBe(-50.0);
expect(parseAmountInput("-12.34")).toBe(-12.34);
expect(parseAmountInput(" 99,99 ")).toBe(99.99);
});
test("CH-M3.2: parseAmountInput returns null for corrupted strings without crashing or returning NaN", () => {
const corrupted = [
"abc",
"",
" ",
"NaN",
"Infinity",
"-Infinity",
"€€€",
"EUR",
"foo-bar-123",
"12.34.56.78",
",,,",
"... ",
"$$$123",
"[object Object]",
"undefined",
"null",
];
for (const val of corrupted) {
const parsed = parseAmountInput(val);
if (parsed !== null) {
expect(Number.isFinite(parsed)).toBe(true);
}
}
});
test("CH-M3.3: Editing Gross Amount to 0.00 € recalculates Net and Tax to 0.00 without division by zero", () => {
const rcpt = createTestReceipt({
totalAmount: { value: 119.0, confidence: 0.9 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
});
const updated = {
...rcpt,
totalAmount: { ...rcpt.totalAmount, value: 0.0 },
};
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(result.totalAmount.value).toBe(0.0);
expect(result.netAmount).toBe(0.0);
expect(result.taxBreakdown[0].taxAmount).toBe(0.0);
expect(result.taxBreakdown[0].netAmount).toBe(0.0);
expect(Number.isNaN(result.netAmount ?? 0)).toBe(false);
expect(Number.isFinite(result.netAmount ?? 0)).toBe(true);
expect(result.validation.isMathValid).toBe(true);
});
test("CH-M3.4: Editing Gross Amount to negative value (refund / credit note) distributes proportionally", () => {
const rcpt = createTestReceipt({
totalAmount: { value: 119.0, confidence: 0.9 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
lineItems: [], // No positive line items
});
const updated = {
...rcpt,
totalAmount: { ...rcpt.totalAmount, value: -119.0 },
};
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(result.totalAmount.value).toBe(-119.0);
expect(result.netAmount).toBe(-100.0);
expect(result.taxBreakdown[0].taxAmount).toBe(-19.0);
expect(result.taxBreakdown[0].netAmount).toBe(-100.0);
// Net + tax sum precisely equals negative gross (-100 + -19 = -119)
expect((result.netAmount ?? 0) + (result.taxBreakdown[0]?.taxAmount ?? 0)).toBe(-119.0);
});
test("CH-M3.5: Multi-tax rate gross recalculation with 19%, 7%, and 0% taxes", () => {
const multiTax = createTestReceipt({
totalAmount: { value: 126.0, confidence: 0.9 },
netAmount: 110.0,
taxBreakdown: [
{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 },
{ ratePercent: 7, taxAmount: 0.7, netAmount: 10.0 },
{ ratePercent: 0, taxAmount: 0.0, netAmount: 0.0 },
],
});
const updated = {
...multiTax,
totalAmount: { ...multiTax.totalAmount, value: 252.0 },
};
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(result.totalAmount.value).toBe(252.0);
// Gross doubled: 126 -> 252. Net and tax should double accordingly
const sumTaxes = totalTaxOf(result);
const net = netOf(result);
expect(Math.round((net + sumTaxes) * 100) / 100).toBe(252.0);
expect(result.validation.isMathValid).toBe(true);
});
test("CH-M3.6: Editing Net Amount holds Gross constant and re-derives taxes", () => {
const rcpt = createTestReceipt({
totalAmount: { value: 119.0, confidence: 0.9 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
});
const updated = {
...rcpt,
netAmount: 90.0,
};
const result = recalculateReceipt(updated, { editedField: "netAmount" });
expect(result.totalAmount.value).toBe(119.0);
expect(result.netAmount).toBe(90.0);
expect(result.taxBreakdown[0].taxAmount).toBe(29.0); // 119 - 90 = 29
expect(result.taxBreakdown[0].netAmount).toBe(90.0);
});
test("CH-M3.7: Rapid sequential edits across multiple fields preserve integrity", () => {
let rcpt = createTestReceipt();
// 1. Edit Merchant
rcpt = recalculateReceipt(
{
...rcpt,
merchant: { ...rcpt.merchant, name: "Neue Gastronomie Berlin" },
editedFields: { merchant: true },
},
{ editedField: "merchant" }
);
expect(rcpt.merchant.name).toBe("Neue Gastronomie Berlin");
expect(rcpt.merchant.confidence).toBe(1.0);
// 2. Edit Date
rcpt = recalculateReceipt(
{
...rcpt,
date: { ...rcpt.date, isoDate: "2026-08-01" },
editedFields: { ...rcpt.editedFields, date: true },
},
{ editedField: "date" }
);
expect(rcpt.date.isoDate).toBe("2026-08-01");
expect(rcpt.date.confidence).toBe(1.0);
// 3. Edit Gross
rcpt = recalculateReceipt(
{
...rcpt,
totalAmount: { ...rcpt.totalAmount, value: 595.0 },
editedFields: { ...rcpt.editedFields, totalAmount: true },
},
{ editedField: "totalAmount" }
);
expect(rcpt.totalAmount.value).toBe(595.0);
expect(rcpt.netAmount).toBe(500.0);
expect(rcpt.taxBreakdown[0].taxAmount).toBe(95.0);
expect(rcpt.totalAmount.confidence).toBe(1.0);
// 4. Edit Category
rcpt = recalculateReceipt(
{
...rcpt,
suggestedCategory: "Reisekosten & Hotel",
editedFields: { ...rcpt.editedFields, category: true },
},
{ editedField: "category" }
);
expect(rcpt.suggestedCategory).toBe("Reisekosten & Hotel");
expect(rcpt.validation.isMathValid).toBe(true);
});
});
describe("CHALLENGER-M3: Bulk Export Generation & Security Sanitization", () => {
const dataset: ProcessedReceipt[] = [
createTestReceipt({
id: "exp-1",
merchant: { name: 'Firma "Test & Co." GmbH', address: "München", taxId: "DE1", confidence: 1.0 },
receiptNumber: "INV-001",
totalAmount: { value: 119.0, confidence: 1.0 },
suggestedCategory: "Bürobedarf & IT",
}),
createTestReceipt({
id: "exp-2",
merchant: { name: "=1+1; -- Formula Injection Test", address: "Frankfurt", taxId: "DE2", confidence: 1.0 },
receiptNumber: "INV-002",
totalAmount: { value: 53.5, confidence: 1.0 },
netAmount: 50.0,
taxBreakdown: [{ ratePercent: 7, taxAmount: 3.5, netAmount: 50.0 }],
suggestedCategory: "Bewirtung",
hospitality: { occasion: "Kundengespräch & Akquise", participants: "Max Mustermann, Erika Musterfrau" },
}),
createTestReceipt({
id: "exp-3",
merchant: { name: "@SUM(A1:A100) \n Multiline \r\n Carriage", address: "Hamburg", taxId: "DE3", confidence: 1.0 },
receiptNumber: "INV-003",
totalAmount: { value: 200.0, confidence: 1.0 },
suggestedCategory: "Tanken & KFZ",
}),
];
test("CH-M3.8: Bulk export for SELECTED items only excludes unselected records", async () => {
const selectedIds = ["exp-1", "exp-3"];
const selectedReceipts = dataset.filter((r) => selectedIds.includes(r.id));
expect(selectedReceipts).toHaveLength(2);
expect(selectedReceipts.some((r) => r.id === "exp-2")).toBe(false);
// CSV
const csv = generateAccountingCsv(selectedReceipts);
expect(csv).toContain("INV-001");
expect(csv).toContain("INV-003");
expect(csv.includes("INV-002")).toBe(false);
// Excel
const xlsxBuffer = await generateDualSheetExcel(selectedReceipts);
expect(xlsxBuffer).toBeDefined();
expect(xlsxBuffer.length).toBeGreaterThan(2000);
});
test("CH-M3.9: Bulk export with EMPTY list handles cleanly without throwing", async () => {
const emptyCsv = generateAccountingCsv([]);
expect(emptyCsv).toBeDefined();
expect(emptyCsv.charCodeAt(0)).toBe(0xfeff); // UTF-8 BOM
expect(emptyCsv).toContain("Laufende Nr");
const emptyXlsx = await generateDualSheetExcel([]);
expect(emptyXlsx).toBeDefined();
expect(emptyXlsx.length).toBeGreaterThan(500);
});
test("CH-M3.10: Accounting CSV properly quotes and escapes semicolons, quotes, and newlines", () => {
const csv = generateAccountingCsv(dataset);
// Must start with UTF-8 BOM
expect(csv.charCodeAt(0)).toBe(0xfeff);
// Double quotes must be escaped as ""
expect(csv).toContain('""Test & Co.""');
// Formula-injection payloads (=, +, - @, tab, CR) are neutralized with a
// leading apostrophe (OWASP CSV-injection mitigation) before quoting, so
// the cell is exported as safe text, never as an evaluable formula
expect(csv).toContain("'=1+1; -- Formula Injection Test\"");
// Hospitality details must be exported when present
expect(csv).toContain("Kundengespräch & Akquise");
expect(csv).toContain("Max Mustermann, Erika Musterfrau");
});
test("CH-M3.11: Dual-Sheet Excel generates both Belegübersicht and Einzelpositionen Detail sheets", async () => {
const buffer = await generateDualSheetExcel(dataset);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(5000);
// Verify it's a valid ZIP / XLSX header (PK\x03\x04)
expect(buffer[0]).toBe(0x50); // 'P'
expect(buffer[1]).toBe(0x4b); // 'K'
expect(buffer[2]).toBe(0x03);
expect(buffer[3]).toBe(0x04);
});
});
describe("CHALLENGER-M3: Bulk Status Updates & Batch Operations", () => {
const mockBatch = [
createTestReceipt({ id: "b1", status: "needs_review", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, userConfirmed: false, reviewField: "taxBreakdown", reviewReason: "Diff", issues: [{ field: "taxBreakdown", severity: "error", message: "Diff" }] } }),
createTestReceipt({ id: "b2", status: "ready", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: false, reviewField: "none", reviewReason: null, issues: [] } }),
createTestReceipt({ id: "b3", status: "ready", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: false, reviewField: "none", reviewReason: null, issues: [] } }),
];
test("CH-M3.12: Bulk Status Update to 'confirmed' verifies all selected items and clears review flags", () => {
const selectedIds = ["b1", "b2"];
const updatedBatch = mockBatch.map((r) =>
selectedIds.includes(r.id) ? confirmReceiptReviewed(r) : r
);
const b1Updated = updatedBatch.find((r) => r.id === "b1");
const b2Updated = updatedBatch.find((r) => r.id === "b2");
const b3Updated = updatedBatch.find((r) => r.id === "b3");
expect(b1Updated?.validation.userConfirmed).toBe(true);
expect(b1Updated?.validation.needsUserReview).toBe(false);
expect(resolveReceiptStatusTier(b1Updated!)).toBe("confirmed");
expect(b2Updated?.validation.userConfirmed).toBe(true);
expect(resolveReceiptStatusTier(b2Updated!)).toBe("confirmed");
expect(b3Updated?.validation.userConfirmed).toBe(false);
expect(resolveReceiptStatusTier(b3Updated!)).toBe("scanned");
});
test("CH-M3.13: Bulk Categorize updates category and marks editedFields", () => {
const selectedIds = ["b1", "b3"];
const targetCategory: ReceiptCategory = "Tanken & KFZ";
const updatedBatch = mockBatch.map((r) => {
if (selectedIds.includes(r.id)) {
const withCat = {
...r,
suggestedCategory: targetCategory,
editedFields: { ...(r.editedFields || {}), category: true },
};
return recalculateReceipt(withCat, { editedField: "category" });
}
return r;
});
expect(updatedBatch.find((r) => r.id === "b1")?.suggestedCategory).toBe("Tanken & KFZ");
expect(updatedBatch.find((r) => r.id === "b2")?.suggestedCategory).toBe("Bewirtung");
expect(updatedBatch.find((r) => r.id === "b3")?.suggestedCategory).toBe("Tanken & KFZ");
});
});
describe("CHALLENGER-M3: Filter & Selection Mathematical Invariants", () => {
const testList: ProcessedReceipt[] = [
createTestReceipt({ id: "t1", date: { isoDate: "2026-08-15", time: "10:00", confidence: 1.0 }, totalAmount: { value: 25.0, confidence: 1.0 }, suggestedCategory: "Bewirtung" }),
createTestReceipt({ id: "t2", date: { isoDate: "2026-08-14", time: "12:00", confidence: 1.0 }, totalAmount: { value: 75.0, confidence: 1.0 }, suggestedCategory: "Tanken & KFZ", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, userConfirmed: false, reviewField: "taxBreakdown", reviewReason: "Diff", issues: [] } }),
createTestReceipt({ id: "t3", date: { isoDate: "2026-07-01", time: "09:00", confidence: 1.0 }, totalAmount: { value: 350.0, confidence: 1.0 }, suggestedCategory: "Bürobedarf & IT", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: true, reviewField: "none", reviewReason: null, issues: [] } }),
createTestReceipt({ id: "t4", date: { isoDate: "2026-01-10", time: "15:00", confidence: 1.0 }, totalAmount: { value: 12.0, confidence: 1.0 }, suggestedCategory: "Sonstiges" }),
];
test("CH-M3.14: Status count partition invariant holds (all === scanned + pending + confirmed)", () => {
let scanned = 0;
let pending = 0;
let confirmed = 0;
for (const r of testList) {
const tier = resolveReceiptStatusTier(r);
if (tier === "pending_review") pending++;
else if (tier === "confirmed") confirmed++;
else scanned++;
}
expect(scanned + pending + confirmed).toBe(testList.length);
expect(scanned).toBe(2);
expect(pending).toBe(1);
expect(confirmed).toBe(1);
});
test("CH-M3.15: Category collection and tax rate collection are deterministic sets", () => {
const taxRates = collectTaxRates(testList);
expect(Array.isArray(taxRates)).toBe(true);
expect(taxRates).toContain(19);
const categories = Array.from(new Set(testList.map((r) => r.suggestedCategory)));
expect(categories).toHaveLength(4);
expect(categories).toContain("Bewirtung");
expect(categories).toContain("Tanken & KFZ");
expect(categories).toContain("Bürobedarf & IT");
expect(categories).toContain("Sonstiges");
});
});