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>
463 lines
17 KiB
TypeScript
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");
|
|
});
|
|
});
|