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>
549 lines
21 KiB
TypeScript
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,
|
|
};
|
|
}
|