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>
612 lines
22 KiB
TypeScript
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");
|
|
});
|
|
});
|