/** * Sprint A: scanner robustness — persistence blob, imageHash length, * scan error mapping, file-size constants. */ import { describe, test, expect } from "./runner"; import { DocumentReadError, UnsupportedFileTypeError } from "../../src/lib/ingest/acceptedTypes"; import { SANITIZE_LIMITS, sanitizeReceipt } from "../../src/lib/ingest/sanitize"; import { IMAGE_HASH_MAX_LENGTH, dbRowToProcessedReceipt, pageImageHash, toExtractionJson, } from "../../src/lib/storage/receiptRow"; import { jsonForScanError } from "../../src/lib/http/scanErrors"; import { processReceiptDocument } from "../../src/lib/image/processor"; import { HEIC_DECODE_ERROR_MESSAGE } from "../../src/lib/image/heic"; import { MAX_RECEIPTS_JSON_BYTES, MAX_UPLOAD_BYTES, MAX_UPLOAD_MB } from "../../src/lib/limits"; import { PENDING_VALIDATION, ProcessedReceipt } from "../../src/lib/schema/receipt"; function sampleReceipt(overrides: Partial = {}): ProcessedReceipt { return { id: "rcpt_sprint_a", merchant: { name: "Trattoria Bella Vista", address: "Marktplatz 12, 10115 Berlin", taxId: "DE987654321", confidence: 0.92, }, date: { isoDate: "2026-08-12", time: "20:15", confidence: 0.91 }, documentType: "BEWIRTUNGSBELEG", receiptNumber: "TR-44201", currency: "EUR", totalAmount: { value: 31.8, confidence: 0.96 }, netAmount: 26.72, tipAmount: 5, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }], lineItems: [{ description: "Pasta", quantity: 1, price: 31.8, taxRate: 19 }], suggestedCategory: "Bewirtung", hospitality: { occasion: "Geschäftsesen", participants: "Müller, Schmidt" }, validation: { ...PENDING_VALIDATION }, imageHash: "a".repeat(64) + "_p1", originalFileName: "bewirtung.jpg", fileSizeBytes: 2048, previewUrl: "data:image/jpeg;base64,abc", createdAt: "2026-08-12T10:00:00.000Z", updatedAt: "2026-08-12T10:00:00.000Z", status: "ready", ...overrides, }; } describe("Sprint A — Dateigrößen", () => { test("Upload-Limit ist 10 MB", () => { expect(MAX_UPLOAD_MB).toBe(10); expect(MAX_UPLOAD_BYTES).toBe(10 * 1024 * 1024); }); test("Receipts-JSON-Limit fasst eine max-große Preview", () => { expect(MAX_RECEIPTS_JSON_BYTES).toBeGreaterThan(SANITIZE_LIMITS.previewUrl); expect(MAX_RECEIPTS_JSON_BYTES).toBeGreaterThan(5 * 1024 * 1024); }); }); describe("Sprint A — imageHash", () => { test("Sanitize-Limit und DB-Limit sind 128", () => { expect(SANITIZE_LIMITS.imageHash).toBe(128); expect(IMAGE_HASH_MAX_LENGTH).toBe(128); }); test("Mehrseiten-Hash (SHA-256 + _p20) bleibt unter 128 und wird akzeptiert", () => { const hash = pageImageHash("a".repeat(64), 20, 20); expect(hash.length).toBe(68); expect(hash.length).toBeLessThanOrEqual(IMAGE_HASH_MAX_LENGTH); const sanitized = sanitizeReceipt(sampleReceipt({ imageHash: hash })); expect(sanitized).not.toBeNull(); expect(sanitized!.imageHash).toBe(hash); }); test("Einzelseite behält den reinen SHA-256", () => { const source = "b".repeat(64); expect(pageImageHash(source, 1, 1)).toBe(source); }); }); describe("Sprint A — extraction_json", () => { test("toExtractionJson entfernt die Preview, behält Adresse/Tax-ID/Zeit/Hospitality", () => { const blob = toExtractionJson(sampleReceipt()); expect(blob.previewUrl).toBeUndefined(); expect((blob.merchant as ProcessedReceipt["merchant"]).address).toBe("Marktplatz 12, 10115 Berlin"); expect((blob.merchant as ProcessedReceipt["merchant"]).taxId).toBe("DE987654321"); expect((blob.date as ProcessedReceipt["date"]).time).toBe("20:15"); expect((blob.hospitality as ProcessedReceipt["hospitality"])?.occasion).toBe("Geschäftsesen"); expect(blob.originalFileName).toBe("bewirtung.jpg"); expect((blob.merchant as ProcessedReceipt["merchant"]).confidence).toBe(0.92); }); test("GET rekonstruiert Felder aus extraction_json statt sie zu nullen", () => { const original = sampleReceipt(); const row = { id: original.id, projectId: null, imageHash: original.imageHash, storageUrl: "data:image/avif;base64,preview", merchantName: original.merchant.name, receiptDate: original.date.isoDate, receiptNumber: original.receiptNumber, documentType: original.documentType, category: original.suggestedCategory, currency: original.currency, totalAmount: "31.80", netAmount: "26.72", tipAmount: "5.00", taxBreakdownJson: original.taxBreakdown, lineItemsJson: original.lineItems, validationJson: original.validation, rawOcrText: null, paymentMethod: null, isMathValid: true, needsReview: false, createdAt: new Date(original.createdAt), updatedAt: new Date(original.updatedAt), extractionJson: toExtractionJson(original), }; const restored = dbRowToProcessedReceipt(row); expect(restored.merchant.address).toBe("Marktplatz 12, 10115 Berlin"); expect(restored.merchant.taxId).toBe("DE987654321"); expect(restored.merchant.confidence).toBe(0.92); expect(restored.date.time).toBe("20:15"); expect(restored.date.confidence).toBe(0.91); expect(restored.hospitality?.participants).toBe("Müller, Schmidt"); expect(restored.originalFileName).toBe("bewirtung.jpg"); expect(restored.fileSizeBytes).toBe(2048); expect(restored.previewUrl).toBe("data:image/avif;base64,preview"); expect(restored.tipAmount).toBe(5); }); test("Altdatensätze ohne extraction_json bleiben lesbar", () => { const restored = dbRowToProcessedReceipt({ id: "legacy", projectId: null, imageHash: "c".repeat(64), storageUrl: null, merchantName: "REWE", receiptDate: "2026-01-01", receiptNumber: null, documentType: "KASSENBON", category: "Sonstiges", currency: "EUR", totalAmount: "10.00", netAmount: null, tipAmount: null, taxBreakdownJson: [], lineItemsJson: [], validationJson: null, rawOcrText: null, paymentMethod: null, isMathValid: true, needsReview: false, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }); expect(restored.merchant.name).toBe("REWE"); expect(restored.merchant.address).toBeNull(); expect(restored.totalAmount.value).toBe(10); }); }); describe("Sprint A — DocumentReadError", () => { test("DocumentReadError wird als 422 mit der echten Meldung gemeldet", async () => { const res = jsonForScanError(new DocumentReadError("Das PDF konnte nicht gelesen werden.")); expect(res.status).toBe(422); const body = await res.json(); expect(body.error).toBe("Das PDF konnte nicht gelesen werden."); }); test("UnsupportedFileTypeError bleibt 415", async () => { const res = jsonForScanError(new UnsupportedFileTypeError("Dateityp nicht erlaubt.", "gif")); expect(res.status).toBe(415); }); test("unbekannte Fehler bleiben 500 ohne interne Details", async () => { const res = jsonForScanError(new Error("ECONNRESET from OpenRouter")); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toBe("Fehler bei der Belegverarbeitung"); }); test("kaputtes HEIC wird als DocumentReadError abgelehnt, nicht als unbekannter Typ", async () => { const fakeHeic = Buffer.from("\x00\x00\x00\x18ftypheic\x00\x00\x00\x00", "latin1"); try { await processReceiptDocument(fakeHeic, "image/heic"); throw new Error("expected DocumentReadError"); } catch (err) { expect(err).toBeInstanceOf(DocumentReadError); expect((err as DocumentReadError).message).toBe(HEIC_DECODE_ERROR_MESSAGE); } }); });