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

234 lines
8.9 KiB
TypeScript

/**
* Milestone 3 (R3): Interactive Live Table & Batch Operations — Adversarial Stress Suite
* Empirical Challenger Verification
*
* Stress-tests:
* 1. Filter edge cases: special characters, regex meta-chars in search (`.*+?^${}()`), empty strings, whitespace-only, emojis
* 2. Amount boundary testing: 0.00 €, negative amounts, high numbers (999999.99 €), German comma vs English dot decimals
* 3. Temporal edge cases: Leap years, boundary dates (Jan 1 / Dec 31), invalid dates, malformed ISO strings
* 4. Multi-selection boundary stress: Rapid toggle, select-all with 0 items, select-all with 1000 items, invalid range IDs
* 5. Bulk operation stress: Bulk export with empty list, bulk export with mixed tax rates, bulk categorization on corrupted records
* 6. Inline editing stress: Rapid successive edits, invalid date strings, NaN gross amounts, missing merchant names
* 7. Status tier resolution stress: Null validation, missing issues, partial receipts, corrupt status enums
*/
import { describe, test, it, expect, beforeEach } from "./runner";
import { ProcessedReceipt, ReceiptCategory } from "../../src/lib/schema/receipt";
import {
resolveReceiptStatusTier,
getStatusTierMeta,
} 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";
function createStressReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id: "stress-rcpt-001",
imageHash: "hash-stress-999",
originalFileName: "stress_receipt.jpg",
fileSizeBytes: 200000,
previewUrl: "blob:http://localhost/stress.jpg",
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready",
documentType: "KASSENBON",
receiptNumber: "STR-001",
currency: "EUR",
merchant: {
name: "Standard Merchant",
address: "Musterstr. 1, Berlin",
taxId: "DE123456789",
confidence: 0.95,
},
date: {
isoDate: "2026-08-15",
time: "12:00",
confidence: 0.95,
},
totalAmount: {
value: 100.0,
confidence: 0.95,
},
netAmount: 84.03,
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
lineItems: [],
suggestedCategory: "Sonstiges",
paymentMethod: "BAR",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
userConfirmed: false,
reviewField: "none",
reviewReason: null,
issues: [],
},
...overrides,
};
}
describe("Milestone 3 (R3) Adversarial: Search Query & Filter Robustness", () => {
const receipts = [
createStressReceipt({
id: "r1",
merchant: { name: "Bäckerei Müller (GmbH & Co. KG)", address: "Hauptstr. [42]", taxId: "DE999", confidence: 0.9 },
receiptNumber: "INV-2026/08+01",
date: { isoDate: "2026-08-15", time: "08:00", confidence: 0.9 },
totalAmount: { value: 12.5, confidence: 0.9 },
suggestedCategory: "Bewirtung",
}),
createStressReceipt({
id: "r2",
merchant: { name: "Shell Tankstelle *** Sonderaktion ***", address: "Autobahn A8", taxId: "DE888", confidence: 0.9 },
receiptNumber: "SH-$$$-99",
date: { isoDate: "2026-08-10", time: "14:00", confidence: 0.9 },
totalAmount: { value: 89.9, confidence: 0.9 },
suggestedCategory: "Tanken & KFZ",
}),
];
test("ADV-M3.1: Search query with regex special characters does not crash matcher", () => {
const specialQueries = ["[42]", "***", "$$$", "+01", "(GmbH", ".*+?^${}()|[]\\"];
for (const query of specialQueries) {
const term = query.toLowerCase();
const filtered = receipts.filter(
(r) =>
r.merchant?.name.toLowerCase().includes(term) ||
r.merchant?.address?.toLowerCase().includes(term) ||
r.receiptNumber?.toLowerCase().includes(term)
);
expect(Array.isArray(filtered)).toBe(true);
}
});
test("ADV-M3.2: Handles German comma vs English dot decimal searches gracefully", () => {
const queryDe = "12,50";
const queryEn = "12.50";
const matchAmount = (q: string) => {
return receipts.filter((r) => {
const gross = (r.totalAmount?.value || 0).toFixed(2);
const grossDe = gross.replace(".", ",");
return gross.includes(q) || grossDe.includes(q);
});
};
expect(matchAmount(queryDe)).toHaveLength(1);
expect(matchAmount(queryEn)).toHaveLength(1);
});
test("ADV-M3.3: Amount range boundary checks handles 0.00 €, high caps, and inverted bounds", () => {
const filterAmount = (min: number | null, max: number | null) => {
return receipts.filter((r) => {
const gross = r.totalAmount?.value || 0;
if (min !== null && gross < min) return false;
if (max !== null && gross > max) return false;
return true;
});
};
expect(filterAmount(0, 0)).toHaveLength(0);
expect(filterAmount(0, 1000000)).toHaveLength(2);
expect(filterAmount(100, 50)).toHaveLength(0); // Inverted bounds returns 0 cleanly
});
});
describe("Milestone 3 (R3) Adversarial: Selection State Integrity", () => {
test("ADV-M3.4: Rapid toggling and duplicate selection handling maintains clean uniqueness", () => {
let selected: string[] = [];
const addOrToggle = (id: string) => {
selected = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id];
};
for (let i = 0; i < 100; i++) {
addOrToggle("item-rapid");
}
// 100 toggles = even number of toggles -> unselected (empty)
expect(selected).toHaveLength(0);
});
test("ADV-M3.5: Range selection with invalid or unlisted boundary IDs handles gracefully", () => {
const list = ["id-1", "id-2", "id-3"];
const selectRange = (from: string, to: string, all: string[]) => {
const idx1 = all.indexOf(from);
const idx2 = all.indexOf(to);
if (idx1 === -1 || idx2 === -1) return [];
const start = Math.min(idx1, idx2);
const end = Math.max(idx1, idx2);
return all.slice(start, end + 1);
};
expect(selectRange("non-existent-1", "id-2", list)).toEqual([]);
expect(selectRange("id-1", "non-existent-2", list)).toEqual([]);
});
});
describe("Milestone 3 (R3) Adversarial: Inline Recalculation & Extreme Math Values", () => {
test("ADV-M3.6: Recalculates gross when amount is set to 0.00 € without NaN or Infinity", () => {
const rcpt = createStressReceipt({
totalAmount: { value: 100.0, confidence: 1.0 },
netAmount: 84.03,
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
});
const updated = {
...rcpt,
totalAmount: { ...rcpt.totalAmount, value: 0.0 },
};
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(isNaN(recalculated.netAmount ?? 0)).toBe(false);
});
test("ADV-M3.7: Recalculates gross with mixed 7% and 19% VAT rates", () => {
const rcpt = createStressReceipt({
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 = {
...rcpt,
totalAmount: { ...rcpt.totalAmount, value: 200.0 },
};
const recalculated = recalculateReceipt(updated, { editedField: "totalAmount" });
expect(recalculated.totalAmount.value).toBe(200.0);
expect(recalculated.validation.isMathValid).toBe(true);
});
});
describe("Milestone 3 (R3) Adversarial: Bulk Export Formats & CSV Encoding", () => {
test("ADV-M3.8: Dual-sheet Excel export generates Sheet 1 and Sheet 2 correctly", async () => {
const largeBatch = Array.from({ length: 25 }, (_, i) =>
createStressReceipt({
id: `batch-stress-${i}`,
merchant: { name: `Händler Nr. ${i}`, address: "Berlin", taxId: "DE123", confidence: 1.0 },
totalAmount: { value: 10.0 * (i + 1), confidence: 1.0 },
})
);
const buffer = await generateDualSheetExcel(largeBatch);
expect(buffer).toBeDefined();
expect(buffer.length).toBeGreaterThan(5000);
});
test("ADV-M3.9: Accounting CSV export handles receipts with quotes, line breaks, and umlauts in merchant name", () => {
const specialReceipt = createStressReceipt({
merchant: { name: 'Möbel "Schön & Weiß" GmbH\nFiliale Süd', address: "München", taxId: "DE1", confidence: 1.0 },
totalAmount: { value: 199.99, confidence: 1.0 },
});
const csv = generateAccountingCsv([specialReceipt]);
expect(csv).toBeDefined();
expect(csv.charCodeAt(0)).toBe(0xfeff); // UTF-8 BOM
expect(csv).toContain("Möbel");
});
});