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>
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
/**
|
|
* 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> = {}): 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);
|
|
}
|
|
});
|
|
});
|