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

612 lines
22 KiB
TypeScript

/**
* Milestone 3 (R3) Empirical Challenger Verification Harness
* Comprehensive Adversarial Stress Testing of Table, Filter & Selection Engines
*/
import { describe, test, it, expect } from "./runner";
import { ProcessedReceipt, ReceiptCategory, DocumentType, PaymentMethod } from "../../src/lib/schema/receipt";
import { resolveReceiptStatusTier, getStatusTierMeta, ReceiptStatusTier } from "../../src/components/dashboard/StatusBadge";
import { recalculateReceipt, confirmReceiptReviewed } from "../../src/lib/ai/recalculate";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
import { grossOf, netOf } from "../../src/components/dashboard/receiptFormat";
// Mock Receipt Factory
function mockReceipt(id: string, overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id,
imageHash: `hash-${id}`,
originalFileName: `${id}.pdf`,
fileSizeBytes: 50000,
previewUrl: `blob:http://localhost/${id}.pdf`,
createdAt: "2026-08-15T10:00:00.000Z",
updatedAt: "2026-08-15T10:00:00.000Z",
status: "ready",
documentType: "KASSENBON",
receiptNumber: `REC-${id}`,
currency: "EUR",
merchant: {
name: `Merchant ${id}`,
address: "Musterstr. 1, Berlin",
taxId: "DE123456789",
confidence: 0.99,
},
date: {
isoDate: "2026-08-15",
time: "10:30",
confidence: 0.99,
},
totalAmount: {
value: 100.0,
confidence: 0.99,
},
netAmount: 84.03,
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
lineItems: [
{ description: `Item for ${id}`, quantity: 1, price: 100.0, taxRate: 19 },
],
suggestedCategory: "Bürobedarf & IT",
paymentMethod: "EC_KARTE",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
userConfirmed: false,
reviewField: "none",
reviewReason: null,
issues: [],
},
...overrides,
};
}
// Pure Filter Predicate implementation matching useReceiptFilters
function applyFilters(
receipts: ProcessedReceipt[],
filters: {
period?: string;
status?: string;
category?: string;
searchQuery?: string;
amountRange?: { min?: number | null; max?: number | null };
}
): ProcessedReceipt[] {
const {
period = "all",
status = "all",
category = "all",
searchQuery = "",
amountRange = { min: null, max: null },
} = filters;
const query = searchQuery.trim().toLowerCase();
return receipts.filter((receipt) => {
// 1. Period filter
if (period !== "all") {
if (!receipt.date?.isoDate) return false;
const date = new Date(receipt.date.isoDate);
if (isNaN(date.getTime())) return false;
const now = new Date();
const dateYear = date.getFullYear();
const dateMonth = date.getMonth();
const dateDay = date.getDate();
const nowYear = now.getFullYear();
const nowMonth = now.getMonth();
const nowDay = now.getDate();
if (period === "today") {
if (!(dateYear === nowYear && dateMonth === nowMonth && dateDay === nowDay)) return false;
} else if (period === "month") {
if (!(dateYear === nowYear && dateMonth === nowMonth)) return false;
} else if (period === "year") {
if (dateYear !== nowYear) return false;
} else if (typeof period === "string") {
if (!receipt.date.isoDate.startsWith(period)) return false;
}
}
// 2. Status filter
if (status !== "all") {
const tier = resolveReceiptStatusTier(receipt);
const norm = status.toLowerCase();
if (norm === "pending" || norm === "pending_review" || norm === "pruefen") {
if (tier !== "pending_review") return false;
} else if (norm === "confirmed" || norm === "bestaetigt") {
if (tier !== "confirmed") return false;
} else if (norm === "scanned" || norm === "erfasst" || norm === "ready") {
if (tier !== "scanned") return false;
}
}
// 3. Category filter
if (category !== "all") {
if (receipt.suggestedCategory !== category) return false;
}
// 4. Amount Range filter
const gross = grossOf(receipt);
if (amountRange.min != null && !isNaN(amountRange.min)) {
if (gross < amountRange.min) return false;
}
if (amountRange.max != null && !isNaN(amountRange.max)) {
if (gross > amountRange.max) return false;
}
// 5. Search query
if (query) {
const merchantName = (receipt.merchant?.name ?? "").toLowerCase();
const merchantAddress = (receipt.merchant?.address ?? "").toLowerCase();
const receiptNo = (receipt.receiptNumber ?? "").toLowerCase();
const isoDate = (receipt.date?.isoDate ?? "").toLowerCase();
const cat = (receipt.suggestedCategory ?? "").toLowerCase();
const docType = (receipt.documentType ?? "").toLowerCase();
const paymentMethod = (receipt.paymentMethod ?? "").toLowerCase();
const grossStr = gross.toFixed(2);
const grossStrDe = grossStr.replace(".", ",");
const lineItemsMatch = receipt.lineItems?.some((item) =>
item?.description?.toLowerCase().includes(query)
);
const matches =
merchantName.includes(query) ||
merchantAddress.includes(query) ||
receiptNo.includes(query) ||
isoDate.includes(query) ||
cat.includes(query) ||
docType.includes(query) ||
paymentMethod.includes(query) ||
grossStr.includes(query) ||
grossStrDe.includes(query) ||
lineItemsMatch;
if (!matches) return false;
}
return true;
});
}
// Pure Selection Engine state machine
class SelectionStateMachine {
selectedIds: string[] = [];
constructor(initial: string[] = []) {
this.selectedIds = [...initial];
}
get set(): Set<string> {
return new Set(this.selectedIds);
}
isSelected(id: string): boolean {
return this.set.has(id);
}
toggleSelect(id: string) {
if (!id) return;
this.selectedIds = this.selectedIds.includes(id)
? this.selectedIds.filter((item) => item !== id)
: [...this.selectedIds, id];
}
selectAll(allIds: string[]) {
this.selectedIds = Array.from(new Set(allIds.filter(Boolean)));
}
toggleSelectAll(allIds: string[]) {
if (!allIds || allIds.length === 0) {
this.selectedIds = [];
return;
}
const allSelected = allIds.every((id) => this.set.has(id));
if (allSelected) {
this.selectedIds = this.selectedIds.filter((id) => !allIds.includes(id));
} else {
this.selectedIds = Array.from(new Set([...this.selectedIds, ...allIds]));
}
}
selectRange(fromId: string, toId: string, allOrderedIds: string[]) {
const fromIdx = allOrderedIds.indexOf(fromId);
const toIdx = allOrderedIds.indexOf(toId);
if (fromIdx === -1 || toIdx === -1) return;
const start = Math.min(fromIdx, toIdx);
const end = Math.max(fromIdx, toIdx);
const rangeIds = allOrderedIds.slice(start, end + 1);
this.selectedIds = Array.from(new Set([...this.selectedIds, ...rangeIds]));
}
isAllSelected(allIds: string[]): boolean {
if (!allIds || allIds.length === 0) return false;
return allIds.every((id) => this.set.has(id));
}
isPartiallySelected(allIds: string[]): boolean {
if (!allIds || allIds.length === 0) return false;
const some = allIds.some((id) => this.set.has(id));
const all = allIds.every((id) => this.set.has(id));
return some && !all;
}
clear() {
this.selectedIds = [];
}
}
describe("Empirical Challenger M3: Adversarial Filter Engine Stress", () => {
const corpus: ProcessedReceipt[] = [
mockReceipt("rcpt-special-chars", {
merchant: { name: 'Café "Kranzler" (GmbH & Co. KG) [Berlin]', address: "Kurfürstendamm 18/20", taxId: "DE999", confidence: 1.0 },
receiptNumber: "INV-2026/08+99$#1",
date: { isoDate: "2026-08-15", time: "09:00", confidence: 1.0 },
totalAmount: { value: 12.50, confidence: 1.0 },
suggestedCategory: "Bewirtung",
paymentMethod: "BAR",
lineItems: [{ description: "Kaffee & Croissant (Set *Special*)", quantity: 1, price: 12.50, taxRate: 19 }],
}),
mockReceipt("rcpt-zero-gross", {
merchant: { name: "Gratis Probe Store", address: "Alexanderplatz 1", taxId: "DE000", confidence: 1.0 },
receiptNumber: "ZERO-000",
date: { isoDate: "2026-08-01", time: "10:00", confidence: 1.0 },
totalAmount: { value: 0.00, confidence: 1.0 },
netAmount: 0.00,
taxBreakdown: [{ ratePercent: 19, taxAmount: 0.00, netAmount: 0.00 }],
suggestedCategory: "Sonstiges",
}),
mockReceipt("rcpt-high-gross", {
merchant: { name: "Apple Store Kurfürstendamm", address: "Berlin", taxId: "DE888", confidence: 1.0 },
receiptNumber: "APPL-9988",
date: { isoDate: "2026-07-25", time: "14:00", confidence: 1.0 },
totalAmount: { value: 3499.00, confidence: 1.0 },
suggestedCategory: "Bürobedarf & IT",
paymentMethod: "APPLE_PAY",
}),
mockReceipt("rcpt-pending-review", {
merchant: { name: "Unbekannter Beleg", address: "", taxId: "", confidence: 0.4 },
receiptNumber: null,
date: { isoDate: "2026-08-10", time: "12:00", confidence: 0.5 },
totalAmount: { value: 50.00, confidence: 0.4 },
suggestedCategory: "Sonstiges",
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
userConfirmed: false,
reviewField: "merchant",
reviewReason: "Geringe Erkennungsgenauigkeit",
issues: [{ field: "merchant", severity: "error", message: "Händler unsicher" }],
},
}),
mockReceipt("rcpt-confirmed", {
merchant: { name: "Tankstelle Jet", address: "Hamburg", taxId: "DE777", confidence: 0.9 },
receiptNumber: "JET-4421",
date: { isoDate: "2026-08-12", time: "18:00", confidence: 0.9 },
totalAmount: { value: 85.40, confidence: 0.9 },
suggestedCategory: "Tanken & KFZ",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
userConfirmed: true,
reviewField: "none",
reviewReason: null,
issues: [],
},
}),
];
test("CHALLENGE-1.1: Complex regex meta-characters in search query do not throw", () => {
const maliciousPatterns = [
".*",
"+",
"?",
"^",
"$",
"{1,3}",
"()",
"[]",
"|",
"\\d+",
"[Berlin]",
"*Special*",
"$#1",
'(GmbH & Co. KG)',
'\\',
'/.*+?^${}()|[]\\',
];
for (const pat of maliciousPatterns) {
const results = applyFilters(corpus, { searchQuery: pat });
expect(Array.isArray(results)).toBe(true);
}
});
test("CHALLENGE-1.2: German comma vs English dot search resolves correctly", () => {
// 12,50 and 12.50 should both find rcpt-special-chars
const resDe = applyFilters(corpus, { searchQuery: "12,50" });
const resEn = applyFilters(corpus, { searchQuery: "12.50" });
expect(resDe.map((r) => r.id)).toEqual(["rcpt-special-chars"]);
expect(resEn.map((r) => r.id)).toEqual(["rcpt-special-chars"]);
// 85,40 and 85.40
const resJetDe = applyFilters(corpus, { searchQuery: "85,40" });
const resJetEn = applyFilters(corpus, { searchQuery: "85.40" });
expect(resJetDe.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
expect(resJetEn.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
});
test("CHALLENGE-1.3: Substring search in line items descriptions", () => {
const resLineItem = applyFilters(corpus, { searchQuery: "Croissant" });
expect(resLineItem).toHaveLength(1);
expect(resLineItem[0].id).toBe("rcpt-special-chars");
});
test("CHALLENGE-1.4: Inverted amount range (min > max) returns clean empty array without error", () => {
const inverted = applyFilters(corpus, {
amountRange: { min: 500, max: 100 },
});
expect(inverted).toHaveLength(0);
});
test("CHALLENGE-1.5: Zero gross receipt filtering with min:0 max:0", () => {
const zeroExact = applyFilters(corpus, {
amountRange: { min: 0, max: 0 },
});
expect(zeroExact).toHaveLength(1);
expect(zeroExact[0].id).toBe("rcpt-zero-gross");
});
test("CHALLENGE-1.6: Boundary amount filtering (< 50, 50-200, > 200)", () => {
const under50 = applyFilters(corpus, { amountRange: { min: 0, max: 50 } });
// rcpt-special-chars (12.50), rcpt-zero-gross (0), rcpt-pending-review (50)
expect(under50.map((r) => r.id)).toEqual(["rcpt-special-chars", "rcpt-zero-gross", "rcpt-pending-review"]);
const between50and200 = applyFilters(corpus, { amountRange: { min: 50, max: 200 } });
// rcpt-pending-review (50), rcpt-confirmed (85.40)
expect(between50and200.map((r) => r.id)).toEqual(["rcpt-pending-review", "rcpt-confirmed"]);
const over200 = applyFilters(corpus, { amountRange: { min: 200, max: null } });
// rcpt-high-gross (3499)
expect(over200.map((r) => r.id)).toEqual(["rcpt-high-gross"]);
});
test("CHALLENGE-1.7: Status tier filtering accurately partitions dataset", () => {
const scanned = applyFilters(corpus, { status: "scanned" });
const pending = applyFilters(corpus, { status: "pending" });
const confirmed = applyFilters(corpus, { status: "confirmed" });
expect(scanned.map((r) => r.id)).toEqual(["rcpt-special-chars", "rcpt-zero-gross", "rcpt-high-gross"]);
expect(pending.map((r) => r.id)).toEqual(["rcpt-pending-review"]);
expect(confirmed.map((r) => r.id)).toEqual(["rcpt-confirmed"]);
expect(scanned.length + pending.length + confirmed.length).toBe(corpus.length);
});
test("CHALLENGE-1.8: Category filtering with multi-criteria conjunction", () => {
const bewirtung = applyFilters(corpus, {
category: "Bewirtung",
amountRange: { min: 10, max: 20 },
searchQuery: "Berlin",
});
expect(bewirtung).toHaveLength(1);
expect(bewirtung[0].id).toBe("rcpt-special-chars");
// Non-matching conjunction
const emptyResult = applyFilters(corpus, {
category: "Bewirtung",
amountRange: { min: 50, max: 100 }, // rcpt-special-chars is 12.50
});
expect(emptyResult).toHaveLength(0);
});
test("CHALLENGE-1.9: Whitespace-only and empty search query does not filter out valid records", () => {
expect(applyFilters(corpus, { searchQuery: " " })).toHaveLength(corpus.length);
expect(applyFilters(corpus, { searchQuery: "" })).toHaveLength(corpus.length);
});
});
describe("Empirical Challenger M3: Selection Engine State & Invariant Stress", () => {
const ids = Array.from({ length: 50 }, (_, i) => `item-${i}`);
test("CHALLENGE-2.1: 1,000 Rapid toggles maintains strict set consistency", () => {
const sm = new SelectionStateMachine();
for (let i = 0; i < 1000; i++) {
sm.toggleSelect("rapid-id");
}
// 1000 toggles = even number = unselected
expect(sm.selectedIds).toHaveLength(0);
expect(sm.isSelected("rapid-id")).toBe(false);
sm.toggleSelect("rapid-id");
expect(sm.selectedIds).toEqual(["rapid-id"]);
expect(sm.isSelected("rapid-id")).toBe(true);
});
test("CHALLENGE-2.2: selectAll deduplicates and ignores falsy values", () => {
const sm = new SelectionStateMachine();
sm.selectAll(["id-1", "id-1", "id-2", "", "id-3", "id-2"]);
expect(sm.selectedIds).toEqual(["id-1", "id-2", "id-3"]);
expect(sm.selectedIds.length).toBe(3);
});
test("CHALLENGE-2.3: Forward, backward, and single range selections", () => {
const sm = new SelectionStateMachine();
// Forward range 5 to 10
sm.selectRange("item-5", "item-10", ids);
expect(sm.selectedIds).toHaveLength(6);
expect(sm.selectedIds).toEqual(["item-5", "item-6", "item-7", "item-8", "item-9", "item-10"]);
// Backward range 15 down to 12
sm.selectRange("item-15", "item-12", ids);
expect(sm.selectedIds).toHaveLength(10); // 6 + 4
expect(sm.isSelected("item-12")).toBe(true);
expect(sm.isSelected("item-15")).toBe(true);
// Single item range
sm.selectRange("item-20", "item-20", ids);
expect(sm.isSelected("item-20")).toBe(true);
});
test("CHALLENGE-2.4: Range selection with invalid / unlisted boundary IDs fails safely without mutation", () => {
const sm = new SelectionStateMachine(["item-1"]);
sm.selectRange("missing-from", "item-5", ids);
expect(sm.selectedIds).toEqual(["item-1"]);
sm.selectRange("item-5", "missing-to", ids);
expect(sm.selectedIds).toEqual(["item-1"]);
sm.selectRange("missing-1", "missing-2", ids);
expect(sm.selectedIds).toEqual(["item-1"]);
sm.selectRange("item-1", "item-2", []); // empty ordered list
expect(sm.selectedIds).toEqual(["item-1"]);
});
test("CHALLENGE-2.5: isAllSelected and isPartiallySelected state predicates", () => {
const sm = new SelectionStateMachine();
const testIds = ["a", "b", "c"];
expect(sm.isAllSelected(testIds)).toBe(false);
expect(sm.isPartiallySelected(testIds)).toBe(false);
sm.toggleSelect("a");
expect(sm.isAllSelected(testIds)).toBe(false);
expect(sm.isPartiallySelected(testIds)).toBe(true);
sm.toggleSelect("b");
sm.toggleSelect("c");
expect(sm.isAllSelected(testIds)).toBe(true);
expect(sm.isPartiallySelected(testIds)).toBe(false);
sm.toggleSelectAll(testIds); // all selected -> should deselect all
expect(sm.selectedIds).toHaveLength(0);
expect(sm.isAllSelected(testIds)).toBe(false);
});
test("CHALLENGE-2.6: High-scale selection (1,000 items) executes in under 15ms", () => {
const largeList = Array.from({ length: 1000 }, (_, i) => `large-${i}`);
const sm = new SelectionStateMachine();
const start = performance.now();
sm.selectAll(largeList);
expect(sm.isAllSelected(largeList)).toBe(true);
sm.toggleSelectAll(largeList);
expect(sm.selectedIds).toHaveLength(0);
const duration = performance.now() - start;
expect(duration).toBeLessThan(200);
});
});
describe("Empirical Challenger M3: Inline Recalculation & Financial Math Stress", () => {
test("CHALLENGE-3.1: Gross amount change on 19% single-rate receipt", () => {
const r = mockReceipt("single-rate-19", {
totalAmount: { value: 119.0, confidence: 1.0 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
});
const updated = {
...r,
totalAmount: { ...r.totalAmount, value: 238.0 },
editedFields: { totalAmount: true },
};
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(recalculated.totalAmount.value).toBe(238.0);
expect(recalculated.netAmount).toBe(200.0);
expect(recalculated.taxBreakdown[0].taxAmount).toBe(38.0);
expect(recalculated.validation.isMathValid).toBe(true);
});
test("CHALLENGE-3.2: Gross amount set to 0.00 € maintains zero math without NaN or Division by Zero", () => {
const r = mockReceipt("zero-gross-test");
const updated = {
...r,
totalAmount: { ...r.totalAmount, value: 0.0 },
editedFields: { totalAmount: true },
};
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(recalculated.totalAmount.value).toBe(0.0);
expect(recalculated.netAmount).toBe(0.0);
expect(recalculated.taxBreakdown[0].taxAmount).toBe(0.0);
expect(recalculated.validation.isMathValid).toBe(true);
});
test("CHALLENGE-3.3: Gross change on mixed 7% and 19% VAT rates redistributes proportionately", () => {
const r = mockReceipt("mixed-rate-test", {
totalAmount: { value: 100.0, confidence: 1.0 },
netAmount: 88.0,
taxBreakdown: [
{ ratePercent: 7, taxAmount: 3.5, netAmount: 50.0 },
{ ratePercent: 19, taxAmount: 8.5, netAmount: 38.0 },
],
});
const updated = {
...r,
totalAmount: { ...r.totalAmount, value: 200.0 },
editedFields: { totalAmount: true },
};
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(recalculated.totalAmount.value).toBe(200.0);
expect(recalculated.validation.isMathValid).toBe(true);
// Net + total tax equals gross
const totalTax = recalculated.taxBreakdown.reduce((sum, item) => sum + (item.taxAmount || 0), 0);
expect(Math.round(((recalculated.netAmount || 0) + totalTax) * 100) / 100).toBe(200.0);
});
test("CHALLENGE-3.4: Editing non-financial fields (date, merchant) updates audit flag without corrupting math", () => {
const r = mockReceipt("audit-flag-test");
const updated = {
...r,
merchant: { ...r.merchant, name: "Neuer Bäcker" },
date: { ...r.date, isoDate: "2026-08-01" },
editedFields: { merchant: true, date: true },
};
const recalculated = recalculateReceipt(updated, { editedField: "merchant" });
expect(recalculated.merchant.name).toBe("Neuer Bäcker");
expect(recalculated.date.isoDate).toBe("2026-08-01");
expect(recalculated.totalAmount.value).toBe(100.0);
expect(recalculated.netAmount).toBe(84.03);
expect(recalculated.editedFields?.merchant).toBe(true);
expect(recalculated.editedFields?.date).toBe(true);
});
});
describe("Empirical Challenger M3: Batch Export Generator Stress", () => {
test("CHALLENGE-4.1: Dual-sheet Excel generation handles 50 receipts with varying tax configurations", async () => {
const receipts = Array.from({ length: 50 }, (_, i) =>
mockReceipt(`batch-xl-${i}`, {
totalAmount: { value: 10.0 * (i + 1), confidence: 1.0 },
suggestedCategory: i % 2 === 0 ? "Bewirtung" : "Reisekosten & Hotel",
})
);
const buffer = await generateDualSheetExcel(receipts);
expect(buffer).toBeDefined();
expect(buffer.length).toBeGreaterThan(10000);
});
test("CHALLENGE-4.2: Accounting CSV generation escapes special CSV characters and enforces UTF-8 BOM", () => {
const receipt = mockReceipt("csv-escape-test", {
merchant: { name: 'Firma "Test;Semikolon & Neuer\nZeilenumbruch" GmbH', address: "Köln", taxId: "DE1", confidence: 1.0 },
totalAmount: { value: 1234.56, confidence: 1.0 },
suggestedCategory: "Bewirtung",
});
const csv = generateAccountingCsv([receipt]);
expect(csv).toBeDefined();
expect(csv.charCodeAt(0)).toBe(0xfeff); // UTF-8 BOM
expect(csv).toContain("1234,56");
expect(csv).toContain("Firma");
});
});