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>
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
/**
|
|
* Milestone 2 (R2): Side-by-Side Receipt Inspector & Split Review Modal — Adversarial Stress Suite
|
|
* Empirical Challenger Verification
|
|
*
|
|
* Stress-tests:
|
|
* 1. Modal lifecycle: mount/unmount, null/undefined receipt resilience, dynamic receipt swapping
|
|
* 2. Rapid navigation: indexing boundary enforcement, cyclic navigation, rapid back-and-forth
|
|
* 3. Keyboard shortcuts: Alt+Left/Right receipt navigation and Escape modal dismiss
|
|
* 4. Audit trail & Field reverts: Multi-field dirty tracking, 1-click revert across all field types
|
|
* 5. Corrupted & partial data resilience: missing fields, null nested objects, negative amounts, 0 line items
|
|
* 6. Bounding box edge cases: extreme coordinates, NaN, negative, boundary hit-testing, empty layout generation
|
|
* 7. LineItemsEditor stress: high-precision decimals, 0 quantity, fractional quantities, deletion to empty array
|
|
* 8. Responsive mobile tab switching: state synchronization and view toggle
|
|
*/
|
|
|
|
import { describe, test, it, expect, beforeEach } from "./runner";
|
|
import {
|
|
ProcessedReceipt,
|
|
ReceiptData,
|
|
PaymentMethodSchema,
|
|
BoundingBoxRectSchema,
|
|
ReceiptBoundingBoxesSchema,
|
|
ReceiptExtractionSchema,
|
|
} from "../../src/lib/schema/receipt";
|
|
import {
|
|
clampPercent,
|
|
createBoundingBoxRect,
|
|
pixelRectToPercent,
|
|
isPointInsideBox,
|
|
generateDefaultBoundingBoxes,
|
|
getFieldBoundingBox,
|
|
getFieldLabel,
|
|
findFieldAtCoordinates,
|
|
} from "../../src/lib/utils/boundingBoxes";
|
|
import { recalculateReceipt, confirmReceiptReviewed, receiptNeedsAttention } from "../../src/lib/ai/recalculate";
|
|
|
|
function createAdversarialReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
|
|
const base: ProcessedReceipt = {
|
|
id: "adv-rcpt-001",
|
|
imageHash: "adv-hash-999",
|
|
originalFileName: "stress_test_receipt.png",
|
|
fileSizeBytes: 180000,
|
|
previewUrl: "blob:http://localhost/adv-receipt.png",
|
|
createdAt: "2026-08-15T12:00:00.000Z",
|
|
updatedAt: "2026-08-15T12:00:00.000Z",
|
|
status: "ready",
|
|
documentType: "KASSENBON",
|
|
receiptNumber: "ADV-2026-001",
|
|
currency: "EUR",
|
|
merchant: {
|
|
name: "Adversarial Hardware GmbH",
|
|
address: "Musterstraße 42, 80331 München",
|
|
taxId: "DE987654321",
|
|
confidence: 0.95,
|
|
},
|
|
date: {
|
|
isoDate: "2026-08-15",
|
|
time: "15:45",
|
|
confidence: 0.99,
|
|
},
|
|
totalAmount: {
|
|
value: 119.00,
|
|
confidence: 0.98,
|
|
},
|
|
netAmount: 100.00,
|
|
taxBreakdown: [
|
|
{ ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 },
|
|
],
|
|
lineItems: [
|
|
{
|
|
description: "Cat6 Ethernet Kabel 10m",
|
|
quantity: 2,
|
|
unitPrice: 25.00,
|
|
price: 50.00,
|
|
taxRate: 19,
|
|
},
|
|
{
|
|
description: "Gigabit Switch 8-Port",
|
|
quantity: 1,
|
|
unitPrice: 69.00,
|
|
price: 69.00,
|
|
taxRate: 19,
|
|
},
|
|
],
|
|
suggestedCategory: "Bürobedarf & IT",
|
|
paymentMethod: "EC_KARTE",
|
|
validation: {
|
|
isMathValid: true,
|
|
isDuplicateSuspected: false,
|
|
needsUserReview: false,
|
|
reviewField: "none",
|
|
reviewReason: null,
|
|
issues: [],
|
|
userConfirmed: false,
|
|
},
|
|
originalExtraction: {
|
|
merchant: {
|
|
name: "Adversarial Hardware GmbH",
|
|
address: "Musterstraße 42, 80331 München",
|
|
taxId: "DE987654321",
|
|
confidence: 0.95,
|
|
},
|
|
date: {
|
|
isoDate: "2026-08-15",
|
|
time: "15:45",
|
|
confidence: 0.99,
|
|
},
|
|
totalAmount: {
|
|
value: 119.00,
|
|
confidence: 0.98,
|
|
},
|
|
netAmount: 100.00,
|
|
taxBreakdown: [
|
|
{ ratePercent: 19, taxAmount: 19.00, netAmount: 100.00 },
|
|
],
|
|
lineItems: [
|
|
{
|
|
description: "Cat6 Ethernet Kabel 10m",
|
|
quantity: 2,
|
|
unitPrice: 25.00,
|
|
price: 50.00,
|
|
taxRate: 19,
|
|
},
|
|
{
|
|
description: "Gigabit Switch 8-Port",
|
|
quantity: 1,
|
|
unitPrice: 69.00,
|
|
price: 69.00,
|
|
taxRate: 19,
|
|
},
|
|
],
|
|
suggestedCategory: "Bürobedarf & IT",
|
|
documentType: "KASSENBON",
|
|
receiptNumber: "ADV-2026-001",
|
|
},
|
|
};
|
|
|
|
return { ...base, ...overrides };
|
|
}
|
|
|
|
describe("Milestone 2: Adversarial Lifecycle, Navigation & Keyboard Event Stress", () => {
|
|
test("ADV-M2.1: Rapid receipt switching updates active form data and clears dirty state cleanly", () => {
|
|
const receiptA = createAdversarialReceipt({ id: "rcpt-A", merchant: { name: "Store A", address: null, taxId: null, confidence: 1.0 } });
|
|
const receiptB = createAdversarialReceipt({ id: "rcpt-B", merchant: { name: "Store B", address: null, taxId: null, confidence: 1.0 } });
|
|
|
|
// Simulate modal state manager
|
|
let currentReceipt: ProcessedReceipt | null = receiptA;
|
|
let editedFields = new Set<string>();
|
|
|
|
// User edits merchant in receipt A
|
|
editedFields.add("merchant");
|
|
expect(editedFields.has("merchant")).toBe(true);
|
|
|
|
// Rapid navigation to receipt B -> should sync to receipt B and reset edited fields
|
|
currentReceipt = receiptB;
|
|
editedFields = new Set<string>();
|
|
|
|
expect(currentReceipt.id).toBe("rcpt-B");
|
|
expect(currentReceipt.merchant.name).toBe("Store B");
|
|
expect(editedFields.size).toBe(0);
|
|
});
|
|
|
|
test("ADV-M2.2: Keyboard shortcut dispatcher processes Alt+ArrowLeft, Alt+ArrowRight and Escape correctly", () => {
|
|
let navigatedDirection: "prev" | "next" | null = null;
|
|
let closed = false;
|
|
|
|
const handleKeyDown = (event: { key: string; altKey?: boolean; preventDefault: () => void }) => {
|
|
if (event.key === "Escape") {
|
|
closed = true;
|
|
} else if (event.altKey && event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
navigatedDirection = "prev";
|
|
} else if (event.altKey && event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
navigatedDirection = "next";
|
|
}
|
|
};
|
|
|
|
let prevented = false;
|
|
const fakePreventDefault = () => { prevented = true; };
|
|
|
|
// Test Alt + Left
|
|
prevented = false;
|
|
handleKeyDown({ key: "ArrowLeft", altKey: true, preventDefault: fakePreventDefault });
|
|
expect(navigatedDirection).toBe("prev");
|
|
expect(prevented).toBe(true);
|
|
|
|
// Test Alt + Right
|
|
prevented = false;
|
|
handleKeyDown({ key: "ArrowRight", altKey: true, preventDefault: fakePreventDefault });
|
|
expect(navigatedDirection).toBe("next");
|
|
expect(prevented).toBe(true);
|
|
|
|
// Test Escape (Dismiss Modal)
|
|
handleKeyDown({ key: "Escape", preventDefault: fakePreventDefault });
|
|
expect(closed).toBe(true);
|
|
|
|
// Test Unrelated key (no action)
|
|
navigatedDirection = null;
|
|
prevented = false;
|
|
handleKeyDown({ key: "ArrowLeft", altKey: false, preventDefault: fakePreventDefault });
|
|
expect(navigatedDirection).toBeNull();
|
|
expect(prevented).toBe(false);
|
|
});
|
|
|
|
test("ADV-M2.3: Boundary navigation clamping prevents out-of-bounds index overflow or underflow", () => {
|
|
const totalReceipts = 3;
|
|
let currentIndex = 0;
|
|
|
|
const navigate = (direction: "prev" | "next") => {
|
|
if (direction === "prev") {
|
|
currentIndex = Math.max(0, currentIndex - 1);
|
|
} else {
|
|
currentIndex = Math.min(totalReceipts - 1, currentIndex + 1);
|
|
}
|
|
};
|
|
|
|
// Attempt to navigate backwards at lower bound (index 0)
|
|
navigate("prev");
|
|
expect(currentIndex).toBe(0);
|
|
navigate("prev");
|
|
expect(currentIndex).toBe(0);
|
|
|
|
// Navigate to upper bound
|
|
navigate("next");
|
|
expect(currentIndex).toBe(1);
|
|
navigate("next");
|
|
expect(currentIndex).toBe(2);
|
|
|
|
// Attempt to navigate forward past upper bound
|
|
navigate("next");
|
|
expect(currentIndex).toBe(2);
|
|
navigate("next");
|
|
expect(currentIndex).toBe(2);
|
|
});
|
|
|
|
test("ADV-M2.4: Debounced auto-save timer handles rapid unmount without memory leaks or race conditions", async () => {
|
|
let savedReceipt: ProcessedReceipt | null = null;
|
|
let saveCount = 0;
|
|
let timer: any = null;
|
|
|
|
const triggerEdit = (newReceipt: ProcessedReceipt) => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
savedReceipt = newReceipt;
|
|
saveCount++;
|
|
}, 50);
|
|
};
|
|
|
|
// Trigger 5 rapid edits within 20ms
|
|
const r1 = createAdversarialReceipt({ id: "edit-1" });
|
|
const r2 = createAdversarialReceipt({ id: "edit-2" });
|
|
const r3 = createAdversarialReceipt({ id: "edit-3" });
|
|
|
|
triggerEdit(r1);
|
|
triggerEdit(r2);
|
|
triggerEdit(r3);
|
|
|
|
// Wait 100ms for debounce timer to settle
|
|
await new Promise((res) => setTimeout(res, 100));
|
|
|
|
// Only the final edit should have been committed
|
|
expect(saveCount).toBe(1);
|
|
expect((savedReceipt as ProcessedReceipt | null)?.id).toBe("edit-3");
|
|
});
|
|
});
|
|
|
|
describe("Milestone 2: Adversarial Field Audit Trail & Revert Capabilities", () => {
|
|
let receipt: ProcessedReceipt;
|
|
|
|
beforeEach(() => {
|
|
receipt = createAdversarialReceipt();
|
|
});
|
|
|
|
test("ADV-M2.5: Comprehensive 1-Click Revert restores each individual field without affecting other edits", () => {
|
|
const editedFields = new Set<string>();
|
|
|
|
// 1. Edit Merchant
|
|
receipt.merchant = { ...receipt.merchant, name: "Modified Merchant Name" };
|
|
editedFields.add("merchant");
|
|
|
|
// 2. Edit Date
|
|
receipt.date = { ...receipt.date, isoDate: "2020-01-01" };
|
|
editedFields.add("date");
|
|
|
|
// 3. Edit Receipt Number
|
|
receipt.receiptNumber = "MODIFIED-NR-999";
|
|
editedFields.add("receiptNumber");
|
|
|
|
// 4. Edit Category
|
|
receipt.suggestedCategory = "Bewirtung";
|
|
editedFields.add("suggestedCategory");
|
|
|
|
// 5. Edit Document Type
|
|
receipt.documentType = "RECHNUNG";
|
|
editedFields.add("documentType");
|
|
|
|
expect(editedFields.size).toBe(5);
|
|
|
|
// Revert only Merchant
|
|
receipt.merchant = { ...receipt.originalExtraction!.merchant! };
|
|
editedFields.delete("merchant");
|
|
|
|
expect(receipt.merchant.name).toBe("Adversarial Hardware GmbH");
|
|
expect(editedFields.has("merchant")).toBe(false);
|
|
expect(editedFields.has("date")).toBe(true);
|
|
expect(receipt.date.isoDate).toBe("2020-01-01"); // Date remains modified
|
|
|
|
// Revert Date
|
|
receipt.date = { ...receipt.originalExtraction!.date! };
|
|
editedFields.delete("date");
|
|
expect(receipt.date.isoDate).toBe("2026-08-15");
|
|
expect(editedFields.has("date")).toBe(false);
|
|
|
|
// Revert Receipt Number
|
|
receipt.receiptNumber = receipt.originalExtraction!.receiptNumber ?? null;
|
|
editedFields.delete("receiptNumber");
|
|
expect(receipt.receiptNumber).toBe("ADV-2026-001");
|
|
|
|
// Revert Category & DocType
|
|
receipt.suggestedCategory = receipt.originalExtraction!.suggestedCategory!;
|
|
editedFields.delete("suggestedCategory");
|
|
receipt.documentType = receipt.originalExtraction!.documentType!;
|
|
editedFields.delete("documentType");
|
|
|
|
expect(editedFields.size).toBe(0);
|
|
expect(receipt.suggestedCategory).toBe("Bürobedarf & IT");
|
|
expect(receipt.documentType).toBe("KASSENBON");
|
|
});
|
|
|
|
test("ADV-M2.6: Reverting financial amount (Gross) triggers automatic tax & net recalculation back to original state", () => {
|
|
// Original: Gross = 119.00, Net = 100.00, MwSt 19% = 19.00
|
|
expect(receipt.totalAmount.value).toBe(119.00);
|
|
|
|
// Modify Gross to 357.00 € (MwSt 19% = 57.00, Net = 300.00)
|
|
const modified = recalculateReceipt(
|
|
{
|
|
...receipt,
|
|
totalAmount: { ...receipt.totalAmount, value: 357.00 },
|
|
},
|
|
{ editedField: "totalAmount" }
|
|
);
|
|
|
|
expect(modified.totalAmount.value).toBe(357.00);
|
|
expect(modified.netAmount).toBe(300.00);
|
|
expect(modified.taxBreakdown?.[0].taxAmount).toBe(57.00);
|
|
|
|
// Revert Gross to original 119.00 €
|
|
const reverted = recalculateReceipt(
|
|
{
|
|
...modified,
|
|
totalAmount: { ...modified.totalAmount, value: receipt.originalExtraction!.totalAmount!.value },
|
|
},
|
|
{ editedField: "totalAmount" }
|
|
);
|
|
|
|
expect(reverted.totalAmount.value).toBe(119.00);
|
|
expect(reverted.netAmount).toBe(100.00);
|
|
expect(reverted.taxBreakdown?.[0].taxAmount).toBe(19.00);
|
|
expect(reverted.validation.isMathValid).toBe(true);
|
|
});
|
|
|
|
test("ADV-M2.7: Line items modification and subsequent revert restores original line item array and cross-sum", () => {
|
|
const originalCount = receipt.originalExtraction?.lineItems?.length || 2;
|
|
expect(receipt.lineItems).toHaveLength(originalCount);
|
|
|
|
// Mutate line items (add new items and change prices)
|
|
receipt.lineItems = [
|
|
{ description: "Item X", quantity: 10, unitPrice: 100, price: 1000, taxRate: 19 },
|
|
];
|
|
expect(receipt.lineItems).toHaveLength(1);
|
|
expect(receipt.lineItems[0].price).toBe(1000);
|
|
|
|
// Revert line items
|
|
receipt.lineItems = [...receipt.originalExtraction!.lineItems!];
|
|
expect(receipt.lineItems).toHaveLength(2);
|
|
expect(receipt.lineItems[0].description).toBe("Cat6 Ethernet Kabel 10m");
|
|
expect(receipt.lineItems[1].description).toBe("Gigabit Switch 8-Port");
|
|
});
|
|
});
|
|
|
|
describe("Milestone 2: Adversarial Partial, Corrupted & Extreme Data Resilience", () => {
|
|
test("ADV-M2.8: Handles receipt with missing / null optional fields without crashing or throwing", () => {
|
|
const partialReceipt: ProcessedReceipt = {
|
|
id: "rcpt-partial-001",
|
|
imageHash: "hash-partial",
|
|
originalFileName: "corrupt_scan.png",
|
|
fileSizeBytes: 10000,
|
|
createdAt: "2026-08-15T00:00:00Z",
|
|
updatedAt: "2026-08-15T00:00:00Z",
|
|
status: "needs_review",
|
|
documentType: "SONSTIGES",
|
|
receiptNumber: null,
|
|
currency: "EUR",
|
|
merchant: { name: "", address: null, taxId: null, confidence: 0.1 },
|
|
date: { isoDate: "", time: null, confidence: 0.1 },
|
|
totalAmount: { value: 0, confidence: 0.1 },
|
|
netAmount: null,
|
|
taxBreakdown: [],
|
|
lineItems: [],
|
|
suggestedCategory: "Sonstiges",
|
|
validation: {
|
|
isMathValid: false,
|
|
isDuplicateSuspected: false,
|
|
needsUserReview: true,
|
|
reviewField: "totalAmount",
|
|
reviewReason: "Bruttobetrag fehlt",
|
|
},
|
|
};
|
|
|
|
// Ensure generateDefaultBoundingBoxes works on empty receipt without crashing
|
|
const boxes = generateDefaultBoundingBoxes(partialReceipt);
|
|
expect(boxes.merchant).toBeDefined();
|
|
expect(boxes.date).toBeDefined();
|
|
expect(boxes.totalAmount).toBeDefined();
|
|
expect(boxes.lineItems).toBeUndefined();
|
|
expect(boxes.taxBreakdown).toBeUndefined();
|
|
|
|
// Recalculation on empty receipt
|
|
const recalculated = recalculateReceipt(partialReceipt);
|
|
expect(recalculated.validation.needsUserReview).toBe(true);
|
|
});
|
|
|
|
test("ADV-M2.9: Bounding box coordinate bounds clamp extreme out-of-range, negative and NaN values", () => {
|
|
// Extreme negative coordinates
|
|
const boxNegative = createBoundingBoxRect(-50, -100, 200, 300);
|
|
expect(boxNegative.x).toBe(0);
|
|
expect(boxNegative.y).toBe(0);
|
|
expect(boxNegative.width).toBe(100);
|
|
expect(boxNegative.height).toBe(100);
|
|
|
|
// Overflow coordinates (x = 80, width = 50 -> clamped width should be 20)
|
|
const boxOverflow = createBoundingBoxRect(80, 70, 50, 50);
|
|
expect(boxOverflow.x).toBe(80);
|
|
expect(boxOverflow.width).toBe(20);
|
|
expect(boxOverflow.y).toBe(70);
|
|
expect(boxOverflow.height).toBe(30);
|
|
|
|
// NaN coordinates
|
|
const boxNaN = createBoundingBoxRect(NaN, NaN, NaN, NaN);
|
|
expect(boxNaN.x).toBe(0);
|
|
expect(boxNaN.y).toBe(0);
|
|
expect(boxNaN.width).toBe(0);
|
|
expect(boxNaN.height).toBe(0);
|
|
});
|
|
|
|
test("ADV-M2.10: Zero-pixel image dimensions in pixelRectToPercent safely fallback to 100% box", () => {
|
|
const pixelRect = { x: 50, y: 50, width: 200, height: 200 };
|
|
const box = pixelRectToPercent(pixelRect, 0, 0);
|
|
|
|
expect(box.x).toBe(0);
|
|
expect(box.y).toBe(0);
|
|
expect(box.width).toBe(100);
|
|
expect(box.height).toBe(100);
|
|
});
|
|
|
|
test("ADV-M2.11: Hit-testing on zero-width or empty bounding boxes does not falsely match", () => {
|
|
const zeroBox = createBoundingBoxRect(20, 20, 0, 0);
|
|
expect(isPointInsideBox({ x: 20, y: 20 }, zeroBox)).toBe(true); // Exact point
|
|
expect(isPointInsideBox({ x: 20.1, y: 20 }, zeroBox)).toBe(false);
|
|
expect(isPointInsideBox({ x: 19.9, y: 20 }, zeroBox)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Milestone 2: Adversarial Line Items Calculations & Numerical Precision", () => {
|
|
test("ADV-M2.12: High-precision decimal rounding handles repeating fractions and floating-point errors (0.1 + 0.2)", () => {
|
|
// 3 items @ 0.33 € each = 0.99 €
|
|
const qty = 3;
|
|
const unitPrice = 0.33;
|
|
const computed = Math.round(qty * unitPrice * 100) / 100;
|
|
expect(computed).toBe(0.99);
|
|
|
|
// Floating-point edge: 7 items @ 0.70 € = 4.90 € (without 4.8999999999999995 bug)
|
|
const qty2 = 7;
|
|
const unitPrice2 = 0.70;
|
|
const computed2 = Math.round(qty2 * unitPrice2 * 100) / 100;
|
|
expect(computed2).toBe(4.90);
|
|
});
|
|
|
|
test("ADV-M2.13: Fractional quantities (e.g. 1.345 kg of fruit or 45.2 liters of fuel)", () => {
|
|
const fuelLiters = 45.28;
|
|
const fuelPricePerLiter = 1.749; // Fuel prices have 3 decimal places in Germany
|
|
const totalPrice = Math.round(fuelLiters * fuelPricePerLiter * 100) / 100;
|
|
|
|
expect(totalPrice).toBe(79.19); // 45.28 * 1.749 = 79.19472 -> 79.19 €
|
|
});
|
|
|
|
test("ADV-M2.14: Line items cross-sum discrepancy tolerance window (0.02 € threshold)", () => {
|
|
const receiptGross = 100.00;
|
|
|
|
// Diff 0.01 € -> within tolerance (e.g. rounding difference)
|
|
const sum1 = 100.01;
|
|
const diff1 = Math.abs(Math.round((sum1 - receiptGross) * 100) / 100);
|
|
expect(diff1 <= 0.02).toBe(true);
|
|
|
|
// Diff 0.02 € -> within tolerance
|
|
const sum2 = 99.98;
|
|
const diff2 = Math.abs(Math.round((sum2 - receiptGross) * 100) / 100);
|
|
expect(diff2 <= 0.02).toBe(true);
|
|
|
|
// Diff 0.03 € -> discrepancy triggered!
|
|
const sum3 = 100.03;
|
|
const diff3 = Math.abs(Math.round((sum3 - receiptGross) * 100) / 100);
|
|
expect(diff3 > 0.02).toBe(true);
|
|
});
|
|
|
|
test("ADV-M2.15: Deleting all line item rows transitions gracefully to empty state without throwing", () => {
|
|
let items = [
|
|
{ description: "Item 1", quantity: 1, price: 10, taxRate: 19 },
|
|
{ description: "Item 2", quantity: 1, price: 20, taxRate: 19 },
|
|
];
|
|
|
|
// Delete item 0
|
|
items = items.filter((_, idx) => idx !== 0);
|
|
expect(items).toHaveLength(1);
|
|
|
|
// Delete remaining item
|
|
items = items.filter((_, idx) => idx !== 0);
|
|
expect(items).toHaveLength(0);
|
|
|
|
const sum = Math.round(items.reduce((acc, curr) => acc + (curr?.price ?? 0), 0) * 100) / 100;
|
|
expect(sum).toBe(0);
|
|
});
|
|
});
|