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>
1019 lines
39 KiB
TypeScript
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
|
|
});
|
|
});
|