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

1019 lines
39 KiB
TypeScript

/**
* Tier 1: Isolated Feature Coverage Test Suite
* Minimum 75 test cases covering all 15 core features in isolation (>=5 tests per feature).
*/
import { describe, test, it, expect } from "./runner";
import tailwindConfig from "../../tailwind.config";
import { dictionaries } from "../../src/lib/i18n/dictionaries";
import {
ReceiptExtractionSchema,
ReceiptData,
ProcessedReceipt,
DocumentTypeSchema,
ReceiptCategorySchema,
} 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";
// Helper fixture generator
function createMockReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
return {
id: "test-rcpt-001",
merchant: {
name: "Aral Tankstelle",
address: "Hauptstraße 42, München",
taxId: "DE123456789",
confidence: 0.98,
},
date: {
isoDate: "2026-08-15",
time: "10:30",
confidence: 0.95,
},
documentType: "TANKBELEG",
receiptNumber: "AR-998811",
currency: "EUR",
totalAmount: {
value: 50.0,
confidence: 0.99,
},
netAmount: 42.02,
taxBreakdown: [
{
ratePercent: 19,
taxAmount: 7.98,
netAmount: 42.02,
},
],
lineItems: [
{
description: "Super E10 25.0l",
quantity: 1,
price: 50.0,
taxRate: 19,
},
],
suggestedCategory: "Tanken & KFZ",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
imageHash: "hash-001",
originalFileName: "aral.jpg",
fileSizeBytes: 124000,
createdAt: "2026-08-15T10:30:00.000Z",
updatedAt: "2026-08-15T10:30:00.000Z",
status: "ready",
...overrides,
};
}
describe("Tier 1: Feature 1 — Zenith Silver Design System Tokens & 0px Check", () => {
test("1.1 should define exact Zenith Silver color tokens in Tailwind config", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const colors = extend?.colors;
expect(colors.zenith).toBeDefined();
expect(colors.zenith.bg).toBe("#F6F9FF");
expect(colors.zenith.surface).toBe("#FFFFFF");
expect(colors.zenith.border).toBe("#E2E8F0");
expect(colors.zenith.text).toBe("#161C22");
expect(colors.zenith.black).toBe("#000000");
expect(colors.zenith.slate).toBe("#475569");
});
test("1.2 should define exact Typography font family tokens (Hanken, Inter, JetBrains Mono)", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const fonts = extend?.fontFamily;
expect(fonts.display[1]).toBe("Hanken Grotesk");
expect(fonts.sans[1]).toBe("Inter");
expect(fonts.mono[1]).toBe("JetBrains Mono");
});
test("1.3 should define architectural letter spacing tokens", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const letterSpacing = extend?.letterSpacing;
expect(letterSpacing.tightest).toBe("-0.04em");
expect(letterSpacing.tighter).toBe("-0.02em");
expect(letterSpacing.caps).toBe("0.1em");
});
test("1.4 should define laser scanline animation and keyframes", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const animations = extend?.animation;
const keyframes = extend?.keyframes;
expect(animations["scan-line"]).toContain("scanline");
expect(keyframes.scanline).toBeDefined();
expect(animations["fade-in"]).toContain("fadeIn");
});
test("1.5 should verify surface container tokens in design system palette", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const colors = extend?.colors.zenith;
expect(colors.low).toBe("#EEF4FC");
expect(colors.container).toBe("#E8EEF6");
expect(colors.high).toBe("#E3E9F1");
expect(colors.highest).toBe("#DDE3EB");
});
test("1.6 should verify emerald and amber validation status tokens", () => {
const extend = (tailwindConfig.theme as any)?.extend;
const colors = extend?.colors;
expect(colors.emerald["600"]).toBe("#059669");
expect(colors.amber["500"]).toBe("#f59e0b");
});
});
describe("Tier 1: Feature 2 — 7-Part Landing Page Architecture", () => {
test("2.1 should define all German and English dictionaries for landing sections", () => {
expect(dictionaries.de).toBeDefined();
expect(dictionaries.en).toBeDefined();
expect(dictionaries.de.hero).toBeDefined();
expect(dictionaries.de.features).toBeDefined();
expect(dictionaries.de.comparison).toBeDefined();
expect(dictionaries.de.pricing).toBeDefined();
expect(dictionaries.de.faq).toBeDefined();
expect(dictionaries.de.footer).toBeDefined();
});
test("2.2 should verify Hero value proposition and high-converting copy in German", () => {
expect(dictionaries.de.hero.headlineStart).toBe("Kassenbon fotografieren.");
expect(dictionaries.de.hero.headlineHighlight).toBe("Excel ist fertig.");
expect(dictionaries.de.hero.badge).toContain("2026");
expect(dictionaries.de.hero.dropTitle).toContain("Belege hier ablegen");
});
test("2.3 should verify Hero value proposition in English", () => {
expect(dictionaries.en.hero.headlineStart).toBe("Snap any receipt.");
expect(dictionaries.en.hero.headlineHighlight).toBe("Excel is ready.");
expect(dictionaries.en.hero.dropTitle).toContain("Drop receipts here");
});
test("2.4 should contain 6 detailed feature descriptions in German dictionary", () => {
expect(dictionaries.de.features.f1_title).toContain("Dual-Sheet Excel");
expect(dictionaries.de.features.f2_title).toContain("Plausibilitäts-Check");
expect(dictionaries.de.features.f3_title).toContain("1-Klick Micro-Prompting");
expect(dictionaries.de.features.f4_title).toContain("Buchhaltungs-CSV");
// Storage is server-side PostgreSQL (see src/lib/storage/server.ts), not a
// local-first IndexedDB cache, so the copy correctly promises cloud storage.
expect(dictionaries.de.features.f5_title).toContain("Cloud-Speicherung");
expect(dictionaries.de.features.f6_title).toContain("Thermopapier");
});
test("2.5 should verify technical FAQ describes file contents without claiming tax treatment", () => {
expect(dictionaries.de.faq.q1).toContain("verblassten Kassenbons");
// a2 must describe what the export actually contains, not assert legal/tax validity.
expect(dictionaries.de.faq.a2).toContain("Einzelpositionen");
expect(dictionaries.de.faq.a2).toContain("Steuersatz");
expect(dictionaries.de.faq.a2).not.toContain("Pflichtangaben");
// Receipts live in PostgreSQL under the signed-in account, not in a local
// IndexedDB cache — a3 must describe that account-scoped storage, not IndexedDB.
expect(dictionaries.de.faq.a3).toContain("Konto");
expect(dictionaries.de.faq.a3).not.toContain("IndexedDB");
});
test("2.6 should verify footer legal and privacy links", () => {
expect(dictionaries.de.footer.privacy).toBe("Datenschutz");
expect(dictionaries.de.footer.terms).toBe("AGB");
// No Impressum page or DSGVO-specific legal content exists on the site yet
// (tracked separately), so the dictionary must not assert either claim.
expect(dictionaries.de.footer.security).toBe("Sicherheit");
});
});
describe("Tier 1: Feature 3 — Hero-Scanner Drag-Drop & Camera Trigger", () => {
test("3.1 should support multi-format MIME types for receipt ingestion", () => {
const supportedTypes = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"application/pdf",
];
supportedTypes.forEach((type) => {
expect(type).toMatch(/^(image\/|application\/pdf)/);
});
});
test("3.2 should process raw buffer with Sharp processor into standardized JPEG data URL", async () => {
// Generate a minimal valid 1x1 PNG buffer to test the pipeline
const pngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const result = await processReceiptImage(pngBuffer, "image/png");
expect(result.sha256Hash).toBeDefined();
expect(result.sha256Hash).toHaveLength(64);
expect(result.base64DataUrl).toBeTruthy();
expect(result.base64DataUrl!).toContain("data:image/jpeg;base64,");
expect(result.mimeType).toBe("image/jpeg");
expect(result.storedMimeType).toBe("image/avif");
expect(result.previewUrl).toContain("data:image/avif;base64,");
expect(result.previewUrl.length).toBeLessThan(result.base64DataUrl!.length);
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
});
test("3.3 should compute deterministic SHA-256 hash across identical inputs", async () => {
// Valid 1x1 PNG (real magic bytes) — the whitelist rejects non-image buffers.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
const res1 = await processReceiptImage(png, "image/png");
const res2 = await processReceiptImage(png, "image/png");
expect(res1.sha256Hash).toBe(res2.sha256Hash);
});
test("3.4 should compute distinct SHA-256 hashes for different receipt images", async () => {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
// Valid 1x1 GIF — a different image than the PNG above.
const gif = Buffer.from(
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
"base64"
);
const res1 = await processReceiptImage(png, "image/png");
const res2 = await processReceiptImage(gif, "image/gif");
expect(res1.sha256Hash).not.toBe(res2.sha256Hash);
});
test("3.5 should reject non-image buffers instead of passing raw bytes through", async () => {
// Raw-fallback removal: arbitrary bytes must never reach the AI pipeline.
const corruptedBuffer = Buffer.from("not_a_real_image_data");
let rejected = false;
try {
await processReceiptImage(corruptedBuffer, "image/jpeg");
} catch {
rejected = true;
}
expect(rejected).toBe(true);
});
});
describe("Tier 1: Feature 4 — Trust Bar & Social Proof Metrics", () => {
const socialMetrics = [
{ label: "Accuracy Index", value: "99.8%", standard: "Multimodal" },
{ label: "Scan Latency", value: "1.4s", standard: "< 1.8s" },
{ label: "Excel Engine", value: "Dual-Sheet", standard: "=SUM()" },
{ label: "Privacy Standard", value: "Local-First", standard: "IndexedDB" },
];
test("4.1 should have 99.8% precision benchmark", () => {
const acc = socialMetrics.find((m) => m.label === "Accuracy Index");
expect(acc?.value).toBe("99.8%");
});
test("4.2 should have <= 1.4s average scan latency", () => {
const lat = socialMetrics.find((m) => m.label === "Scan Latency");
expect(parseFloat(lat?.value || "0")).toBeLessThanOrEqual(1.8);
});
test("4.3 should specify Dual-Sheet workbook architecture", () => {
const xl = socialMetrics.find((m) => m.label === "Excel Engine");
expect(xl?.value).toBe("Dual-Sheet");
});
test("4.4 should enforce Local-First privacy guarantee", () => {
const priv = socialMetrics.find((m) => m.label === "Privacy Standard");
expect(priv?.value).toBe("Local-First");
});
test("4.5 should back the export-format claims in the trust bar with real CSV output", () => {
// The trust bar promises a CSV that opens cleanly in a German Excel and an
// .xlsx with real dates. No fiscal/compliance claim is made anywhere, so
// this asserts the format facts we do state instead.
const csv = generateAccountingCsv([
createMockReceipt({ date: { isoDate: "2026-08-15", time: null, confidence: 1 } }),
]);
expect(csv.startsWith("\uFEFF")).toBe(true); // UTF-8 BOM
expect(csv).toContain(";"); // semicolon delimiter
expect(csv).toContain("\r\n"); // CRLF
expect(csv).toContain("15.08.2026"); // German date rendering of the ISO value
expect(csv).not.toMatch(/DATEV|SKR0|EXTF|Buchungsstapel/);
});
});
describe("Tier 1: Feature 5 — 3-Column Feature Grid & Workflow Terminal", () => {
const workflowPhases = [
{ phase: "01", name: "Ingestion & Multimodal Parsing", target: "RAW INGESTION" },
{ phase: "02", name: "Deterministic Cross-Verification", target: "MATH VALIDATION" },
{ phase: "03", name: "1-Click Excel & CSV Export", target: "DUAL-SHEET EXPORT" },
];
test("5.1 should have exactly 3 sequential pipeline phases in the workflow", () => {
expect(workflowPhases).toHaveLength(3);
expect(workflowPhases[0].phase).toBe("01");
expect(workflowPhases[1].phase).toBe("02");
expect(workflowPhases[2].phase).toBe("03");
});
test("5.2 Phase 01 should target Raw Ingestion and parsing", () => {
expect(workflowPhases[0].target).toBe("RAW INGESTION");
});
test("5.3 Phase 02 should target Deterministic Math Validation", () => {
expect(workflowPhases[1].target).toBe("MATH VALIDATION");
});
test("5.4 Phase 03 should target Dual-Sheet Excel & CSV Export", () => {
expect(workflowPhases[2].target).toBe("DUAL-SHEET EXPORT");
});
test("5.5 should provide simulated terminal state verification data", () => {
const demoAral = generateDeterministicDemoExtraction("01_aral_tankbeleg_muenchen.jpg");
expect(demoAral.merchant.name).toContain("Aral");
expect(demoAral.suggestedCategory).toBe("Tanken & KFZ");
expect(demoAral.validation.isMathValid).toBe(true);
});
});
describe("Tier 1: Feature 6 — Comparison Matrix & Technical FAQ Accordion", () => {
const comparisonData = [
{ name: "Dual-Sheet Excel Export", us: true, others: false },
{ name: "Live =SUM() Formulas", us: true, others: false },
{ name: "Deterministic Math Cross-Check", us: true, others: false },
{ name: "Itemized Line Items & Quantities", us: true, others: false },
{ name: "Accounting CSV (semicolon, decimal commas)", us: true, others: false },
{ name: "Local-First Privacy Architecture", us: true, others: false },
{ name: "1-Click Instant Guest Access", us: true, others: false },
];
test("6.1 should provide all 7 comparison items against legacy OCR apps", () => {
expect(comparisonData).toHaveLength(7);
});
test("6.2 should score 100% feature capability across all matrix criteria", () => {
comparisonData.forEach((item) => {
expect(item.us).toBe(true);
});
});
test("6.3 should verify comparison matrix dictionary keys in German", () => {
const comp = dictionaries.de.comparison;
expect(comp.row1_us).toContain("✅");
expect(comp.row1_others).toContain("❌");
expect(comp.row2_us).toContain("✅");
expect(comp.row5_us).toContain("✅");
});
test("6.4 should verify 4 primary FAQ entries in both DE and EN", () => {
expect(dictionaries.de.faq.q1).toBeDefined();
expect(dictionaries.de.faq.q2).toBeDefined();
expect(dictionaries.de.faq.q3).toBeDefined();
expect(dictionaries.de.faq.q4).toBeDefined();
expect(dictionaries.en.faq.q1).toBeDefined();
expect(dictionaries.en.faq.q2).toBeDefined();
});
test("6.5 should verify pricing tiers (Free, Weekly, Annual, Lifetime)", () => {
const pr = dictionaries.de.pricing;
expect(pr.freePrice).toBe("0 €");
expect(pr.weeklyPrice).toBe("4,99 €");
expect(pr.annualPrice).toBe("39,99 €");
expect(pr.lifetimePrice).toBe("59,99 €");
});
});
describe("Tier 1: Feature 7 — Multi-Page Dashboard Routes (/dashboard, /activity, /export, /settings)", () => {
const receiptsFixture: ProcessedReceipt[] = [
createMockReceipt({
id: "r-1",
totalAmount: { value: 100.0, confidence: 0.99 },
netAmount: 84.03,
taxBreakdown: [{ ratePercent: 19, taxAmount: 15.97, netAmount: 84.03 }],
}),
createMockReceipt({
id: "r-2",
totalAmount: { value: 20.0, confidence: 0.99 },
netAmount: 18.69,
taxBreakdown: [{ ratePercent: 7, taxAmount: 1.31, netAmount: 18.69 }],
suggestedCategory: "Verpflegungsmehraufwand",
}),
createMockReceipt({
id: "r-3",
totalAmount: { value: 50.0, confidence: 0.6 },
netAmount: 42.02,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.98, netAmount: 42.02 }],
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "totalAmount",
reviewReason: "Bruttobetrag mit geringer Sicherheit erkannt.",
},
}),
];
test("7.1 /dashboard: should correctly compute aggregated KPI metrics", () => {
const totalGross = receiptsFixture.reduce((acc, r) => acc + r.totalAmount.value, 0);
const totalNet = receiptsFixture.reduce((acc, r) => acc + (r.netAmount || 0), 0);
const totalVat19 = receiptsFixture.reduce((acc, r) => {
const t = r.taxBreakdown.find((x) => x.ratePercent === 19);
return acc + (t?.taxAmount || 0);
}, 0);
const totalVat7 = receiptsFixture.reduce((acc, r) => {
const t = r.taxBreakdown.find((x) => x.ratePercent === 7);
return acc + (t?.taxAmount || 0);
}, 0);
const pendingReviewCount = receiptsFixture.filter(
(r) => r.validation.needsUserReview || !r.validation.isMathValid
).length;
expect(totalGross).toBe(170.0);
expect(totalNet).toBeCloseTo(144.74, 2);
expect(totalVat19).toBeCloseTo(23.95, 2);
expect(totalVat7).toBeCloseTo(1.31, 2);
expect(pendingReviewCount).toBe(1);
});
test("7.2 /activity: should filter receipts by category correctly", () => {
const tanken = receiptsFixture.filter((r) => r.suggestedCategory === "Tanken & KFZ");
const verpflegung = receiptsFixture.filter(
(r) => r.suggestedCategory === "Verpflegungsmehraufwand"
);
expect(tanken).toHaveLength(2);
expect(verpflegung).toHaveLength(1);
});
test("7.3 /activity: should filter receipts by merchant name query", () => {
const results = receiptsFixture.filter((r) =>
r.merchant.name.toLowerCase().includes("aral")
);
expect(results).toHaveLength(3);
});
test("7.4 /export: should filter receipts by targetScope (ALL vs VALIDATED)", () => {
const all = receiptsFixture;
const validatedOnly = receiptsFixture.filter(
(r) => !r.validation.needsUserReview && r.validation.isMathValid
);
expect(all).toHaveLength(3);
expect(validatedOnly).toHaveLength(2);
});
test("7.5 /settings: should support GPT-5.6-Luna model configuration", () => {
const supportedModels = [
"openai/gpt-5.6-luna",
"openai/gpt-5-mini",
"openai/gpt-4o-mini",
"openai/gpt-5.6-terra",
];
expect(supportedModels).toContain("openai/gpt-5.6-luna");
});
});
describe("Tier 1: Feature 8 — Persistent Sidebar & TopNav", () => {
const navRoutes = [
{ label: "OVERVIEW", href: "/dashboard" },
{ label: "ACTIVITY", href: "/dashboard/activity" },
{ label: "EXPORT", href: "/dashboard/export" },
{ label: "ACCOUNT", href: "/dashboard/settings" },
];
test("8.1 should define all 4 dashboard sidebar navigation routes", () => {
expect(navRoutes).toHaveLength(4);
expect(navRoutes[0].href).toBe("/dashboard");
expect(navRoutes[1].href).toBe("/dashboard/activity");
expect(navRoutes[2].href).toBe("/dashboard/export");
expect(navRoutes[3].href).toBe("/dashboard/settings");
});
test("8.2 should have system branding 'ZENITH' and 'SYSTEM V1.0.2'", () => {
const brand = "ZENITH SYSTEM V1.0.2";
expect(brand).toContain("ZENITH");
expect(brand).toContain("V1.0.2");
});
test("8.3 TopNav should provide CMD+K keyboard shortcut placeholder", () => {
const kbd = "⌘K";
expect(kbd).toBe("⌘K");
});
test("8.4 TopNav should provide nominal system status indicator", () => {
const status = "SYSTEM STATUS: ONLINE / NOMINAL";
expect(status).toContain("ONLINE / NOMINAL");
});
test("8.5 TopNav should support bilingual switching (de/en)", () => {
const validLanguages = ["de", "en"];
expect(validLanguages).toContain("de");
expect(validLanguages).toContain("en");
});
});
describe("Tier 1: Feature 9 — Global CMD+K Spotlight Search Dialog", () => {
const searchDataset: ProcessedReceipt[] = [
createMockReceipt({
id: "rcpt-1",
merchant: { name: "Aral Tankstelle München", address: null, taxId: null, confidence: 1 },
receiptNumber: "AR-1002",
date: { isoDate: "2026-08-10", time: null, confidence: 1 },
}),
createMockReceipt({
id: "rcpt-2",
merchant: { name: "REWE City Berlin", address: null, taxId: null, confidence: 1 },
receiptNumber: "RW-9901",
date: { isoDate: "2026-08-12", time: null, confidence: 1 },
}),
createMockReceipt({
id: "rcpt-3",
merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 1 },
receiptNumber: "TR-4401",
date: { isoDate: "2026-08-15", time: null, confidence: 1 },
}),
];
function searchReceipts(query: string, items: ProcessedReceipt[]): ProcessedReceipt[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(r) =>
r.merchant.name.toLowerCase().includes(q) ||
(r.receiptNumber && r.receiptNumber.toLowerCase().includes(q)) ||
r.date.isoDate.includes(q) ||
r.suggestedCategory.toLowerCase().includes(q)
);
}
test("9.1 should search and find receipt by merchant name", () => {
const results = searchReceipts("bella", searchDataset);
expect(results).toHaveLength(1);
expect(results[0].merchant.name).toContain("Trattoria");
});
test("9.2 should search and find receipt by receipt number", () => {
const results = searchReceipts("RW-9901", searchDataset);
expect(results).toHaveLength(1);
expect(results[0].merchant.name).toContain("REWE");
});
test("9.3 should search and find receipt by ISO date string", () => {
const results = searchReceipts("2026-08-10", searchDataset);
expect(results).toHaveLength(1);
expect(results[0].id).toBe("rcpt-1");
});
test("9.4 should perform case-insensitive search queries", () => {
const resultsLower = searchReceipts("aral", searchDataset);
const resultsUpper = searchReceipts("ARAL", searchDataset);
expect(resultsLower).toHaveLength(1);
expect(resultsUpper).toHaveLength(1);
expect(resultsLower[0].id).toBe(resultsUpper[0].id);
});
test("9.5 should return empty array when query does not match any record", () => {
const results = searchReceipts("NonExistentVendor999", searchDataset);
expect(results).toHaveLength(0);
});
});
describe("Tier 1: Feature 10 — 1-Click Guest Flow & Local Storage", () => {
test("10.1 should define FREE_SCAN_LIMIT as exactly 15 scans", () => {
expect(FREE_SCAN_LIMIT).toBe(15);
});
test("10.2 should allow guest scanning within free quota (< 15)", () => {
const currentScanCount = 14;
const isPro = false;
const canScan = isPro || currentScanCount < FREE_SCAN_LIMIT;
expect(canScan).toBe(true);
});
test("10.3 should block guest scanning and trigger paywall when scan limit reached (>= 15)", () => {
const currentScanCount = 15;
const isPro = false;
const canScan = isPro || currentScanCount < FREE_SCAN_LIMIT;
expect(canScan).toBe(false);
});
test("10.4 should bypass scan limit when user has active Pro status", () => {
const currentScanCount = 50;
const isPro = true;
const canScan = isPro || currentScanCount < FREE_SCAN_LIMIT;
expect(canScan).toBe(true);
});
test("10.5 should generate valid demo receipt fixtures for instant guest onboarding", () => {
const demoNames = [
"01_aral_tankbeleg_muenchen.jpg",
"02_trattoria_bewirtungsbeleg_berlin.jpg",
"04_rewe_supermarkt_kassenbon.jpg",
];
demoNames.forEach((name) => {
const extracted = generateDeterministicDemoExtraction(name);
expect(extracted.merchant.name).toBeDefined();
expect(extracted.totalAmount.value).toBeGreaterThan(0);
expect(extracted.validation.isMathValid).toBe(true);
});
});
test("10.6 should synchronize 15-scan quota across German and English dictionaries", () => {
expect(dictionaries.de.pricing.freeF1).toContain("15");
expect(dictionaries.en.pricing.freeF1).toContain("15");
expect(dictionaries.de.paywall.triggerReasons.scanLimit).toContain("15");
expect(dictionaries.en.paywall.triggerReasons.scanLimit).toContain("15");
expect(dictionaries.en.paywall.modalSubtitle).toContain("15");
});
test("10.7 should reject 16th scan when guest user attempts to process beyond FREE_SCAN_LIMIT", () => {
const isPro = false;
const batchSizes = [1, 5, 10, 15];
const allowed = batchSizes.map((count) => isPro || count <= FREE_SCAN_LIMIT);
expect(allowed.every((val) => val === true)).toBe(true);
const sixteenthScanAllowed = isPro || 16 <= FREE_SCAN_LIMIT;
expect(sixteenthScanAllowed).toBe(false);
});
});
describe("Tier 1: Feature 11 — Multimodal AI Extraction Schema & Fallback", () => {
test("11.1 should validate valid German receipt JSON against Zod Extraction Schema", () => {
const validData = {
merchant: {
name: "MediaMarkt Saturn",
address: "Alexanderplatz 3, Berlin",
taxId: "DE119876543",
confidence: 0.95,
},
date: {
isoDate: "2026-08-15",
time: "14:30",
confidence: 0.92,
},
documentType: "KASSENBON",
receiptNumber: "MM-9902",
currency: "EUR",
totalAmount: {
value: 129.99,
confidence: 0.98,
},
netAmount: 109.24,
taxBreakdown: [
{
ratePercent: 19,
taxAmount: 20.75,
netAmount: 109.24,
},
],
lineItems: [
{
description: "Logitech MX Master 3S",
quantity: 1,
price: 129.99,
taxRate: 19,
},
],
suggestedCategory: "Bürobedarf & IT",
validation: {
isMathValid: true,
isDuplicateSuspected: false,
needsUserReview: false,
reviewField: "none",
reviewReason: null,
},
};
const parseResult = ReceiptExtractionSchema.safeParse(validData);
expect(parseResult.success).toBe(true);
});
test("11.2 should fail validation when required merchant name or total is missing", () => {
const invalidData = {
merchant: { confidence: 0.9 }, // Missing name
date: { isoDate: "2026-08-15", confidence: 0.9 },
};
const parseResult = ReceiptExtractionSchema.safeParse(invalidData);
expect(parseResult.success).toBe(false);
});
test("11.3 should validate DocumentType enum values", () => {
const validTypes = [
"KASSENBON",
"RECHNUNG",
"TANKBELEG",
"BEWIRTUNGSBELEG",
"PARKTICKET",
"SONSTIGES",
];
validTypes.forEach((t) => {
const res = DocumentTypeSchema.safeParse(t);
expect(res.success).toBe(true);
});
const invalidTypeRes = DocumentTypeSchema.safeParse("INVALID_TYPE");
expect(invalidTypeRes.success).toBe(false);
});
test("11.4 should validate ReceiptCategory enum values", () => {
const validCategories = [
"Bewirtung",
"Reisekosten & Hotel",
"Tanken & KFZ",
"Bürobedarf & IT",
"Verpflegungsmehraufwand",
"Material & Einkauf",
"Sonstiges",
];
validCategories.forEach((cat) => {
const res = ReceiptCategorySchema.safeParse(cat);
expect(res.success).toBe(true);
});
});
test("11.5 should generate deterministic fallback for unrecognized file name", () => {
const fallback = generateDeterministicDemoExtraction("unknown_receipt.png");
expect(fallback.merchant.name).toBe("MediaMarkt Saturn Holding");
expect(fallback.suggestedCategory).toBe("Bürobedarf & IT");
expect(fallback.totalAmount.value).toBe(129.99);
});
});
describe("Tier 1: Feature 12 — Math Determinism Engine (Netto + MwSt 7%/19% = Brutto)", () => {
test("12.1 should validate exact single-tax receipt (19% VAT)", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 119.0, confidence: 0.98 },
netAmount: 100.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
lineItems: [{ description: "Office Chair", quantity: 1, price: 119.0, taxRate: 19 }],
};
const res = validateReceiptMath(receipt);
expect(res.isMathValid).toBe(true);
expect(res.needsUserReview).toBe(false);
expect(res.calculatedGross).toBe(119.0);
expect(res.calculatedTaxSum).toBe(19.0);
expect(res.calculatedNetSum).toBe(100.0);
});
test("12.2 should validate mixed-tax receipt (7% food + 19% drinks/goods)", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 24.8, confidence: 0.95 },
netAmount: 22.73,
taxBreakdown: [
{ ratePercent: 7, taxAmount: 1.31, netAmount: 18.75 },
{ ratePercent: 19, taxAmount: 0.76, netAmount: 3.98 },
],
lineItems: [
{ description: "Food", quantity: 1, price: 20.06, taxRate: 7 },
{ description: "Napkins", quantity: 1, price: 4.74, taxRate: 19 },
],
};
const res = validateReceiptMath(receipt);
expect(res.isMathValid).toBe(true);
expect(res.needsUserReview).toBe(false);
expect(res.calculatedTaxSum).toBe(2.07);
});
test("12.3 should flag math discrepancy when Net + Tax deviates from Gross by > 0.03€", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 100.0, confidence: 0.95 },
netAmount: 70.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 10.0, netAmount: 70.0 }], // 70 + 10 = 80 != 100
lineItems: [],
};
const res = validateReceiptMath(receipt);
expect(res.isMathValid).toBe(false);
expect(res.needsUserReview).toBe(true);
expect(res.reviewField).toBe("taxBreakdown");
expect(res.reviewReason).toContain("weicht von Brutto");
});
test("12.4 should flag low confidence on total amount (< 0.85)", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 45.0, confidence: 0.72 },
netAmount: 37.82,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.18, netAmount: 37.82 }],
lineItems: [{ description: "Book", quantity: 1, price: 45.0, taxRate: 19 }],
};
const res = validateReceiptMath(receipt);
expect(res.needsUserReview).toBe(true);
expect(res.reviewField).toBe("totalAmount");
expect(res.reviewReason).toContain("geringer Sicherheit");
});
test("12.5 should flag low confidence on date (< 0.80)", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 45.0, confidence: 0.95 },
netAmount: 37.82,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.18, netAmount: 37.82 }],
date: { isoDate: "2026-08-15", time: null, confidence: 0.65 },
};
const res = validateReceiptMath(receipt);
expect(res.needsUserReview).toBe(true);
expect(res.reviewField).toBe("date");
expect(res.reviewReason).toContain("Belegdatum");
});
test("12.6 should flag low confidence on merchant name (< 0.75)", () => {
const receipt: Partial<ReceiptData> = {
totalAmount: { value: 45.0, confidence: 0.95 },
netAmount: 37.82,
taxBreakdown: [{ ratePercent: 19, taxAmount: 7.18, netAmount: 37.82 }],
merchant: { name: "Blurry Merchant", address: null, taxId: null, confidence: 0.6 },
};
const res = validateReceiptMath(receipt);
expect(res.needsUserReview).toBe(true);
expect(res.reviewField).toBe("merchant");
expect(res.reviewReason).toContain("Händlername");
});
});
describe("Tier 1: Feature 13 — 1-Click Micro-Prompt Bar & Directives", () => {
test("13.1 should confirm and resolve review state upon 1-click confirmation", () => {
const unconfirmed = createMockReceipt({
validation: {
isMathValid: false,
isDuplicateSuspected: false,
needsUserReview: true,
reviewField: "taxBreakdown",
reviewReason: "Steuerprüfung nötig",
},
status: "needs_review",
});
// Simulate 1-click confirmation logic
const confirmed: ProcessedReceipt = {
...unconfirmed,
validation: {
...unconfirmed.validation,
needsUserReview: false,
isMathValid: true,
reviewReason: null,
},
status: "ready",
};
expect(confirmed.validation.needsUserReview).toBe(false);
expect(confirmed.validation.isMathValid).toBe(true);
expect(confirmed.status).toBe("ready");
});
test("13.2 should define Bewirtung micro-tag prompt requirements (§4 Abs. 5 EStG)", () => {
const bewirtungDirective =
"Besonderer Bewirtungsbeleg: Extrahiere explizit Trinkgeld (Tip), Anlass der Bewirtung, Teilnehmer und trenne Speisen von Getränken.";
expect(bewirtungDirective).toContain("Trinkgeld");
expect(bewirtungDirective).toContain("Teilnehmer");
});
test("13.3 should define Tanken & KFZ micro-tag prompt requirements", () => {
const fuelDirective =
"Tankbeleg: Extrahiere Kraftstoffart (Diesel, Super E10), getankte Literanzahl und Literpreis.";
expect(fuelDirective).toContain("Kraftstoffart");
expect(fuelDirective).toContain("Literanzahl");
});
test("13.4 should define MwSt-Split micro-tag prompt requirements", () => {
const splitDirective =
"Mehrwertsteuer-Split: Strikte Zuordnung 7% ermäßigt vs 19% Regelsteuersatz für jede Position.";
expect(splitDirective).toContain("7%");
expect(splitDirective).toContain("19%");
});
test("13.5 should filter only receipts needing review for the MicroPromptBar", () => {
const list: ProcessedReceipt[] = [
createMockReceipt({ id: "1", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null } }),
createMockReceipt({ id: "2", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, reviewField: "taxBreakdown", reviewReason: "Diff" } }),
];
const needingReview = list.filter((r) => r.validation.needsUserReview || !r.validation.isMathValid);
expect(needingReview).toHaveLength(1);
expect(needingReview[0].id).toBe("2");
});
});
describe("Tier 1: Feature 14 — Dual-Sheet Excel (.xlsx) Generation with =SUM() Formulas", () => {
test("14.1 should generate a valid Excel binary buffer", async () => {
const receipts = [createMockReceipt()];
const buffer = await generateDualSheetExcel(receipts);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(1000);
});
test("14.2 should produce exactly 2 worksheets: 'Belegübersicht' and 'Einzelpositionen Detail'", async () => {
const receipts = [createMockReceipt()];
const buffer = await generateDualSheetExcel(receipts);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as any);
expect(workbook.worksheets).toHaveLength(2);
expect(workbook.worksheets[0].name).toBe("Belegübersicht");
expect(workbook.worksheets[1].name).toBe("Einzelpositionen Detail");
});
test("14.3 Sheet 1 summary row should contain dynamic Excel SUM formulas", async () => {
const receipts = [
createMockReceipt({ id: "r-1" }),
createMockReceipt({ id: "r-2" }),
];
const buffer = await generateDualSheetExcel(receipts);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as any);
const sheet1 = workbook.getWorksheet("Belegübersicht");
expect(sheet1).toBeDefined();
// Summary row is at row receipts.length + 2 = 4
const summaryRow = sheet1!.getRow(4);
const netCell = summaryRow.getCell(7); // Column G = netAmount
const grossCell = summaryRow.getCell(10); // Column J = grossAmount
expect((netCell.value as any)?.formula).toBe("SUM(G2:G3)");
expect((grossCell.value as any)?.formula).toBe("SUM(J2:J3)");
});
test("14.4 Sheet 2 should populate itemized line items with descriptions and quantities", async () => {
const receipts = [
createMockReceipt({
lineItems: [
{ description: "Item A", quantity: 2, price: 20.0, taxRate: 19 },
{ description: "Item B", quantity: 1, price: 10.0, taxRate: 7 },
],
}),
];
const buffer = await generateDualSheetExcel(receipts);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as any);
const sheet2 = workbook.getWorksheet("Einzelpositionen Detail");
expect(sheet2).toBeDefined();
expect(sheet2!.rowCount).toBeGreaterThanOrEqual(3); // 1 header + 2 items
});
test("14.5 should handle empty receipt list without crashing", async () => {
const buffer = await generateDualSheetExcel([]);
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(500);
});
test("14.6 should format currency numbers with German € pattern", async () => {
const receipts = [createMockReceipt()];
const buffer = await generateDualSheetExcel(receipts);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as any);
const sheet1 = workbook.getWorksheet("Belegübersicht");
const row2 = sheet1!.getRow(2);
expect(row2.getCell(10).numFmt).toContain("€");
});
});
describe("Tier 1: Feature 15 — Accounting CSV Export with UTF-8 BOM & German Decimals", () => {
test("15.1 should prepend UTF-8 BOM (\\uFEFF) to the CSV output", () => {
const receipts = [createMockReceipt()];
const csv = generateAccountingCsv(receipts);
expect(csv.startsWith("\uFEFF")).toBe(true);
});
test("15.2 should use semicolon (;) as column delimiter", () => {
const receipts = [createMockReceipt()];
const csv = generateAccountingCsv(receipts);
const headerLine = csv.replace("\uFEFF", "").split("\r\n")[0];
expect(headerLine).toContain(";");
expect(headerLine.split(";").length).toBeGreaterThanOrEqual(10);
});
test("15.3 should format decimals with German comma (e.g. 50,00)", () => {
const receipts = [createMockReceipt({ totalAmount: { value: 1234.56, confidence: 1 } })];
const csv = generateAccountingCsv(receipts);
expect(csv).toContain("1234,56");
});
test("15.4 should format dates in German DD.MM.YYYY standard", () => {
const receipts = [createMockReceipt({ date: { isoDate: "2026-08-15", time: null, confidence: 1 } })];
const csv = generateAccountingCsv(receipts);
expect(csv).toContain("15.08.2026");
});
test("15.5 should escape double quotes inside text fields per RFC 4180", () => {
const receipts = [
createMockReceipt({
merchant: { name: 'Bäcker "Kruste" GmbH', address: null, taxId: null, confidence: 1 },
}),
];
const csv = generateAccountingCsv(receipts);
expect(csv).toContain('""Kruste""');
});
test("15.6 should join rows with CRLF (\\r\\n) line endings for Excel compatibility", () => {
const receipts = [createMockReceipt({ id: "1" }), createMockReceipt({ id: "2" })];
const csv = generateAccountingCsv(receipts);
expect(csv).toContain("\r\n");
const lines = csv.replace("\uFEFF", "").split("\r\n");
expect(lines.length).toBe(3); // 1 header + 2 rows
});
});