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>
This commit is contained in:
233
tests/e2e/m3_adversarial.test.ts
Normal file
233
tests/e2e/m3_adversarial.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user