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>
234 lines
8.9 KiB
TypeScript
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");
|
|
});
|
|
});
|