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

549 lines
21 KiB
TypeScript

/**
* Tier 3: Combinatorial Cross-Feature Integration Tests
* Minimum 15 end-to-end multi-module workflow integration test cases.
*/
import { describe, test, it, expect } from "./runner";
import { ProcessedReceipt, ReceiptData } from "../../src/lib/schema/receipt";
import { validateReceiptMath } from "../../src/lib/ai/mathValidator";
import { generateDeterministicDemoExtraction } from "../../src/lib/ai/extractor";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
import { processReceiptImage } from "../../src/lib/image/processor";
import { FREE_SCAN_LIMIT } from "../../src/lib/limits";
import ExcelJS from "exceljs";
describe("Tier 3: Combinatorial Workflows & Cross-Feature Interactions", () => {
// Workflow 1: MicroPrompt TANKEN_KFZ -> Ingestion -> Math Check -> Excel Generator
test("Workflow 1: MicroPrompt (TANKEN_KFZ) -> AI extraction -> Math validation -> Dual-Sheet Excel export", async () => {
// 1. Ingest Aral Tankstelle demo fixture
const rawExtraction = generateDeterministicDemoExtraction("01_aral_tankbeleg_muenchen.jpg");
expect(rawExtraction.suggestedCategory).toBe("Tanken & KFZ");
// 2. Run deterministic math verification
const mathResult = validateReceiptMath(rawExtraction);
expect(mathResult.isMathValid).toBe(true);
expect(mathResult.needsUserReview).toBe(false);
// 3. Construct processed receipt record
const receipt: ProcessedReceipt = {
...rawExtraction,
id: "wf-1-aral",
imageHash: "hash-aral-wf1",
originalFileName: "01_aral_tankbeleg_muenchen.jpg",
fileSizeBytes: 184000,
createdAt: "2026-08-15T08:42:00.000Z",
updatedAt: "2026-08-15T08:42:00.000Z",
status: "ready",
};
// 4. Generate Dual-Sheet Excel Workbook
const excelBuffer = await generateDualSheetExcel([receipt]);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(excelBuffer as any);
// 5. Verify Sheet 1 and Sheet 2 structure
const s1 = workbook.getWorksheet("Belegübersicht");
const s2 = workbook.getWorksheet("Einzelpositionen Detail");
expect(s1).toBeDefined();
expect(s2).toBeDefined();
expect(s1!.getRow(2).getCell(3).value).toBe("Aral Tankstelle Station");
expect(s1!.getRow(2).getCell(10).value).toBe(68.45);
// Verify SUM formula in summary row
const sumGross = s1!.getRow(3).getCell(10);
expect((sumGross.value as any)?.formula).toBe("SUM(J2:J2)");
});
// Workflow 2: MicroPrompt BEWIRTUNG -> AI Tip Extraction -> Accounting CSV Generation
test("Workflow 2: MicroPrompt (BEWIRTUNG) -> AI extract with Tip -> Math check -> accounting CSV export", () => {
// 1. Ingest Trattoria Bella Vista Bewirtungsbeleg fixture
const rawExtraction = generateDeterministicDemoExtraction("02_trattoria_bewirtungsbeleg_berlin.jpg");
expect(rawExtraction.documentType).toBe("BEWIRTUNGSBELEG");
expect(rawExtraction.suggestedCategory).toBe("Bewirtung");
// 2. Validate line items contain tip item and food/drinks
const tipItem = rawExtraction.lineItems.find((i) => i.description.includes("Trinkgeld"));
expect(tipItem).toBeDefined();
expect(tipItem?.price).toBe(12.5);
// 3. Check math validation
const mathResult = validateReceiptMath(rawExtraction);
expect(mathResult.isMathValid).toBe(true);
// 4. Generate accounting CSV
const receipt: ProcessedReceipt = {
...rawExtraction,
id: "wf-2-trattoria",
imageHash: "hash-trattoria-wf2",
originalFileName: "02_trattoria.jpg",
fileSizeBytes: 210000,
createdAt: "2026-08-15T20:15:00.000Z",
updatedAt: "2026-08-15T20:15:00.000Z",
status: "ready",
};
const accountingCsv = generateAccountingCsv([receipt]);
// 5. Verify CSV formatting rules
expect(accountingCsv.startsWith("\uFEFF")).toBe(true);
expect(accountingCsv).toContain('"Trattoria Bella Vista - Bewirtung"');
expect(accountingCsv).toContain('"84,50"');
expect(accountingCsv).toContain('"70,97"');
expect(accountingCsv).toContain('"3,08"'); // 7% VAT
expect(accountingCsv).toContain('"10,45"'); // 19% VAT
});
// Workflow 3: MicroPrompt MWST_SPLIT -> Mixed Supermarket Receipt -> Dual-Sheet Excel
test("Workflow 3: MicroPrompt (MWST_SPLIT) -> Multi-tax receipt -> Math check pass -> Dual-Sheet Excel", async () => {
// 1. Ingest REWE supermarket receipt fixture
const rawExtraction = generateDeterministicDemoExtraction("04_rewe_supermarkt_kassenbon.jpg");
expect(rawExtraction.taxBreakdown).toHaveLength(2); // 7% + 19%
// 2. Verify math consistency
const mathResult = validateReceiptMath(rawExtraction);
expect(mathResult.isMathValid).toBe(true);
const receipt: ProcessedReceipt = {
...rawExtraction,
id: "wf-3-rewe",
imageHash: "hash-rewe-wf3",
originalFileName: "04_rewe.jpg",
fileSizeBytes: 165000,
createdAt: "2026-08-15T17:45:00.000Z",
updatedAt: "2026-08-15T17:45:00.000Z",
status: "ready",
};
// 3. Export to Excel
const buf = await generateDualSheetExcel([receipt]);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as any);
const s1 = wb.getWorksheet("Belegübersicht");
expect(s1?.getRow(2).getCell(8).value).toBe(1.31);
expect(s1?.getRow(2).getCell(9).value).toBe(0.76);
expect(s1?.getRow(2).getCell(10).value).toBe(24.8);
const s2 = wb.getWorksheet("Einzelpositionen Detail");
expect(s2?.rowCount).toBe(5); // 1 header + 4 items
});
// Workflow 4: Multi-File Ingestion Batch -> Category Filtering -> Batch Export
test("Workflow 4: Multi-file batch -> Preprocessing hashes -> Category filter -> Batch export", async () => {
const names = [
"01_aral_tankbeleg_muenchen.jpg",
"02_trattoria_bewirtungsbeleg_berlin.jpg",
"04_rewe_supermarkt_kassenbon.jpg",
];
const processedBatch: ProcessedReceipt[] = names.map((name, i) => {
const ext = generateDeterministicDemoExtraction(name);
return {
...ext,
id: `wf4-${i}`,
imageHash: `hash-wf4-${name}`,
originalFileName: name,
fileSizeBytes: 100000 + i * 50000,
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready",
};
});
expect(processedBatch).toHaveLength(3);
// Filter by 'Tanken & KFZ'
const tankenOnly = processedBatch.filter((r) => r.suggestedCategory === "Tanken & KFZ");
expect(tankenOnly).toHaveLength(1);
expect(tankenOnly[0].merchant.name).toContain("Aral");
// Export full batch to Excel
const buf = await generateDualSheetExcel(processedBatch);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as any);
const s1 = wb.getWorksheet("Belegübersicht");
expect(s1?.rowCount).toBe(5); // 1 header + 3 data rows + 1 total
});
// Workflow 5: Guest Quota Lifecycle (0 -> 14 -> 15 limit reached -> Pro upgrade -> Unlimited)
test("Workflow 5: Guest scan quota lifecycle (0 -> 15 limit -> Pro switch -> unlimited)", () => {
let scanCount = 0;
let isPro = false;
// Scan 1..14
for (let i = 0; i < 14; i++) {
expect(isPro || scanCount < FREE_SCAN_LIMIT).toBe(true);
scanCount++;
}
// Scan 15 (15th free scan allowed)
expect(isPro || scanCount < FREE_SCAN_LIMIT).toBe(true);
scanCount++;
// Scan 16 attempt (blocked for guest)
expect(isPro || scanCount < FREE_SCAN_LIMIT).toBe(false);
// User purchases Pro license
isPro = true;
// Scan 16 attempt now succeeds
expect(isPro || scanCount < FREE_SCAN_LIMIT).toBe(true);
scanCount++;
expect(scanCount).toBe(16);
});
// Workflow 6: Discrepant Receipt -> MicroPrompt Resolution -> Status Update -> Export
test("Workflow 6: Discrepant receipt -> 1-Click MicroPrompt confirmation -> Status Valid -> Export", async () => {
// 1. Create a receipt with a discrepancy
const rawData: ProcessedReceipt = {
...generateDeterministicDemoExtraction("01_aral_tankbeleg_muenchen.jpg"),
id: "wf-6-disc",
imageHash: "hash-wf6",
originalFileName: "faded_receipt.jpg",
fileSizeBytes: 120000,
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
netAmount: 40.0, // 40 + 10.93 != 68.45
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "taxBreakdown",
reviewReason: "Netto + MwSt weicht von Brutto ab",
},
status: "needs_review",
};
expect(rawData.status).toBe("needs_review");
expect(rawData.validation.needsUserReview).toBe(true);
// 2. User clicks "Confirm" in MicroPromptBar
const resolvedReceipt: ProcessedReceipt = {
...rawData,
netAmount: 57.52, // Fixed net
validation: {
...rawData.validation,
isMathValid: true,
needsUserReview: false,
reviewReason: null,
},
status: "ready",
};
expect(resolvedReceipt.status).toBe("ready");
expect(resolvedReceipt.validation.isMathValid).toBe(true);
// 3. Export verified receipt
const csv = generateAccountingCsv([resolvedReceipt]);
expect(csv).toContain('"Valide"');
expect(csv).not.toContain('"Prüfung erforderlich"');
});
// Workflow 7: LiveTable Inline Editing -> Math Engine Recalculation -> Export Synchronization
test("Workflow 7: LiveTable inline edit -> Math validation re-evaluation -> Export sync", () => {
const initial = generateDeterministicDemoExtraction("01_aral_tankbeleg_muenchen.jpg");
// Inline edit: user corrects gross amount from 68.45 to 68.50
const editedData: Partial<ReceiptData> = {
...initial,
totalAmount: { value: 68.5, confidence: 1.0 },
};
const revalidation = validateReceiptMath(editedData);
// 57.52 + 10.93 = 68.45 != 68.50 (diff 0.05 > 0.03) -> flagged
expect(revalidation.isMathValid).toBe(false);
expect(revalidation.needsUserReview).toBe(true);
// User also corrects net amount to 57.57 -> 57.57 + 10.93 = 68.50
const correctedData: Partial<ReceiptData> = {
...editedData,
netAmount: 57.57,
};
const correctedValidation = validateReceiptMath(correctedData);
expect(correctedValidation.isMathValid).toBe(true);
});
// Workflow 8: CMD+K Spotlight Search -> Filter & Modal Inspection Selection
test("Workflow 8: CMD+K Spotlight search -> Select matching receipt -> Data binding matches", () => {
const list = [
createMockReceipt({ id: "1", merchant: { name: "Aral", address: null, taxId: null, confidence: 1 } }),
createMockReceipt({ id: "2", merchant: { name: "MediaMarkt", address: null, taxId: null, confidence: 1 } }),
createMockReceipt({ id: "3", merchant: { name: "REWE", address: null, taxId: null, confidence: 1 } }),
];
const searchQuery = "mediamarkt";
const matched = list.filter((r) => r.merchant.name.toLowerCase().includes(searchQuery));
expect(matched).toHaveLength(1);
expect(matched[0].id).toBe("2");
});
// Workflow 9: International Currency Receipt -> Pipeline Execution -> CSV & Excel
test("Workflow 9: International currency receipt (USD) -> Validation -> Excel & CSV generation", async () => {
const usdReceipt: ProcessedReceipt = {
id: "wf-9-usd",
merchant: { name: "Apple Store New York", address: "5th Ave", taxId: null, confidence: 0.99 },
date: { isoDate: "2026-08-10", time: "11:00", confidence: 0.95 },
documentType: "RECHNUNG",
receiptNumber: "INV-US-9912",
currency: "USD",
totalAmount: { value: 108.87, confidence: 0.99 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 8.875, taxAmount: 8.87, netAmount: 100.0 }],
lineItems: [{ description: "Magic Mouse 3", quantity: 1, price: 108.87, taxRate: 8.875 }],
suggestedCategory: "Bürobedarf & IT",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
imageHash: "hash-usd-wf9",
originalFileName: "apple_ny.jpg",
fileSizeBytes: 140000,
createdAt: "2026-08-10T11:00:00.000Z",
updatedAt: "2026-08-10T11:00:00.000Z",
status: "ready",
};
const val = validateReceiptMath(usdReceipt);
expect(val.isMathValid).toBe(true);
const csv = generateAccountingCsv([usdReceipt]);
expect(csv).toContain('"Apple Store New York"');
expect(csv).toContain('"108,87"');
const buf = await generateDualSheetExcel([usdReceipt]);
expect(buf.length).toBeGreaterThan(1000);
});
// Workflow 10: Multi-Receipt Batch Export with Excel Dynamic SUM Spanning All Rows
test("Workflow 10: 10-Receipt batch -> Dual-Sheet Excel with total formula =SUM(G2:G11)", async () => {
const batch = Array.from({ length: 10 }, (_, i) => {
const ext = generateDeterministicDemoExtraction();
return {
...ext,
id: `batch-${i}`,
imageHash: `hash-batch-${i}`,
originalFileName: `receipt_${i}.jpg`,
fileSizeBytes: 120000,
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready" as const,
};
});
const buf = await generateDualSheetExcel(batch);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as any);
const s1 = wb.getWorksheet("Belegübersicht");
const summaryRow = s1!.getRow(12); // 1 header + 10 rows + 1 summary
const netSum = summaryRow.getCell(7);
const grossSum = summaryRow.getCell(10);
expect((netSum.value as any)?.formula).toBe("SUM(G2:G11)");
expect((grossSum.value as any)?.formula).toBe("SUM(J2:J11)");
});
// Workflow 11: Image Preprocessing Hash -> Duplicate Ingestion Alert
test("Workflow 11: Image Preprocessing -> Hash computation -> Duplicate flag detection", async () => {
// Valid 1x1 PNG (real magic bytes) — identical bytes, identical hashes.
const png1x1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const p1 = await processReceiptImage(png1x1, "image/png");
const p2 = await processReceiptImage(png1x1, "image/png");
expect(p1.sha256Hash).toBe(p2.sha256Hash);
// Simulate existing store check
const existingHashes = new Set([p1.sha256Hash]);
const isDuplicate = existingHashes.has(p2.sha256Hash);
expect(isDuplicate).toBe(true);
});
// Workflow 12: Tax-Exempt Invoices (0% VAT) -> Excel & CSV Export
test("Workflow 12: Tax-exempt invoice -> Math check 0% tax -> Excel & CSV export", async () => {
const taxExemptReceipt: ProcessedReceipt = {
id: "wf-12-exempt",
merchant: { name: "Deutsche Post AG", address: "Bonn", taxId: null, confidence: 0.98 },
date: { isoDate: "2026-08-14", time: "09:10", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "DP-88712",
currency: "EUR",
totalAmount: { value: 35.0, confidence: 0.98 },
netAmount: 35.0,
taxBreakdown: [{ ratePercent: 0, taxAmount: 0.0, netAmount: 35.0 }],
lineItems: [{ description: "Briefmarken Set 50x", quantity: 1, price: 35.0, taxRate: 0 }],
suggestedCategory: "Bürobedarf & IT",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
imageHash: "hash-post-wf12",
originalFileName: "post.jpg",
fileSizeBytes: 110000,
createdAt: "2026-08-14T09:10:00.000Z",
updatedAt: "2026-08-14T09:10:00.000Z",
status: "ready",
};
const val = validateReceiptMath(taxExemptReceipt);
expect(val.isMathValid).toBe(true);
expect(val.calculatedTaxSum).toBe(0.0);
const csv = generateAccountingCsv([taxExemptReceipt]);
expect(csv).toContain('"35,00"');
const buf = await generateDualSheetExcel([taxExemptReceipt]);
expect(buf.length).toBeGreaterThan(1000);
});
// Workflow 13: Discount Voucher + Pfand Return Combination
test("Workflow 13: Discounted receipt with Pfand return -> Line item cross-sum -> Excel detail", async () => {
const discountReceipt: ProcessedReceipt = {
id: "wf-13-disc",
merchant: { name: "EDEKA Center", address: "München", taxId: "DE881122", confidence: 0.96 },
date: { isoDate: "2026-08-15", time: "16:00", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "ED-9912",
currency: "EUR",
totalAmount: { value: 27.5, confidence: 0.98 },
netAmount: 24.3,
taxBreakdown: [
{ ratePercent: 7, taxAmount: 1.4, netAmount: 20.0 },
{ ratePercent: 19, taxAmount: 1.8, netAmount: 4.3 },
],
lineItems: [
{ description: "Einkauf Lebensmittel", quantity: 1, price: 21.4, taxRate: 7 },
{ description: "Haushaltswaren", quantity: 1, price: 11.1, taxRate: 19 },
{ description: "Treuerabatt Coupon", quantity: 1, price: -5.0, taxRate: 19 },
{ description: "Pfandrückgabe", quantity: 1, price: -0.0, taxRate: 0 },
],
suggestedCategory: "Material & Einkauf",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
imageHash: "hash-edeka-wf13",
originalFileName: "edeka.jpg",
fileSizeBytes: 140000,
createdAt: "2026-08-15T16:00:00.000Z",
updatedAt: "2026-08-15T16:00:00.000Z",
status: "ready",
};
const val = validateReceiptMath(discountReceipt);
expect(val.calculatedItemsSum).toBe(27.5);
expect(val.isMathValid).toBe(true);
const buf = await generateDualSheetExcel([discountReceipt]);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as any);
const s2 = wb.getWorksheet("Einzelpositionen Detail");
expect(s2?.rowCount).toBe(5); // 1 header + 4 line items
});
// Workflow 14: Category Reassignment -> Category column in CSV
test("Workflow 14: Category reassignment -> updated category column in CSV", () => {
const receipt = createMockReceipt({
suggestedCategory: "Bewirtung",
merchant: { name: "Restaurant Bella", address: null, taxId: null, confidence: 1 },
});
const csvInitial = generateAccountingCsv([receipt]);
expect(csvInitial).toContain('"Restaurant Bella - Bewirtung"');
// Reassign category to 'Reisekosten & Hotel'
const reassigned: ProcessedReceipt = {
...receipt,
suggestedCategory: "Reisekosten & Hotel",
};
const csvUpdated = generateAccountingCsv([reassigned]);
expect(csvUpdated).toContain('"Restaurant Bella - Reisekosten & Hotel"');
});
// Workflow 15: Full End-to-End Lifecycle Execution
test("Workflow 15: Full End-to-End Lifecycle: Ingest -> Preprocess -> Extract -> Validate -> Persist -> Dual Export", async () => {
// 1. Ingest raw image buffer (valid 1x1 PNG — the whitelist rejects
// non-image bytes, so the lifecycle starts from a real image)
const mockImageBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const preprocessed = await processReceiptImage(mockImageBytes, "image/png");
expect(preprocessed.sha256Hash).toBeDefined();
// 2. Multimodal AI Extraction (Deterministic generator)
const extraction = generateDeterministicDemoExtraction("01_aral_tankbeleg_muenchen.jpg");
expect(extraction.merchant.name).toContain("Aral");
// 3. Math Determinism Verification
const mathValidation = validateReceiptMath(extraction);
expect(mathValidation.isMathValid).toBe(true);
// 4. Create full processed receipt record
const processedRecord: ProcessedReceipt = {
...extraction,
id: `lifecycle_${Date.now()}`,
imageHash: preprocessed.sha256Hash,
originalFileName: "aral_tankstelle.jpg",
fileSizeBytes: preprocessed.processedSizeBytes,
previewUrl: preprocessed.base64DataUrl,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "ready",
};
// 5. Generate Dual-Sheet Excel
const excelBuffer = await generateDualSheetExcel([processedRecord]);
expect(excelBuffer.length).toBeGreaterThan(1000);
// 6. Generate accounting CSV
const csvContent = generateAccountingCsv([processedRecord]);
expect(csvContent.startsWith("\uFEFF")).toBe(true);
expect(csvContent).toContain("Aral Tankstelle Station");
});
});
function createMockReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id: "mock-wf-id",
merchant: { name: "Mock Vendor", address: null, taxId: null, confidence: 1 },
date: { isoDate: "2026-08-15", time: null, confidence: 1 },
documentType: "KASSENBON",
receiptNumber: "MOCK-1",
currency: "EUR",
totalAmount: { value: 10.0, confidence: 1 },
netAmount: 8.4,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.6, netAmount: 8.4 }],
lineItems: [{ description: "Item", quantity: 1, price: 10.0, taxRate: 19 }],
suggestedCategory: "Sonstiges",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
imageHash: "hash-mock",
originalFileName: "mock.jpg",
fileSizeBytes: 1000,
createdAt: "2026-08-15T12:00:00.000Z",
updatedAt: "2026-08-15T12:00:00.000Z",
status: "ready",
...overrides,
};
}