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

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);
}
});
});