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

503 lines
18 KiB
TypeScript

/**
* Milestone 1 (R1): Challenger 2 Adversarial Stress Test Suite
* Empirical verification of:
* 1. Batch State Machine Transitions & Concurrency Stress
* 2. Progress Bar Stage Tracking & Aggregate Mathematical Invariants
* 3. Duplicate Detection Logic (Exact SHA-256 & Fuzzy Heuristics & Malformed Data)
* 4. Batch Drawer Lifecycle (Queue Deduplication, Object URL Revocation, Minimized State, Cleanups)
* 5. Dashboard & Activity Route Contract Compliance
*/
import { describe, test, it, expect, beforeEach } from "./runner";
import { ProcessedReceipt } from "../../src/lib/schema/receipt";
// Mock helper
function createMockFile(name: string, sizeBytes: number, mimeType: string): File {
const buffer = new Uint8Array(sizeBytes);
const blob = new Blob([buffer], { type: mimeType });
return new File([blob], name, { type: mimeType, lastModified: Date.now() });
}
interface ItemState {
id: string;
file: File;
fileName: string;
fileSizeBytes: number;
mimeType: string;
previewThumbnailUrl?: string;
isPdf: boolean;
status: "queued" | "preprocessing" | "uploading" | "extracting" | "success" | "duplicate_suspected" | "error";
progressPercent: number;
stageMessage: string;
error?: string;
receiptResult?: ProcessedReceipt;
duplicateMatches?: any[];
pageCount?: number;
}
class StressQueueEngine {
queue: ItemState[] = [];
activeWorkers = new Set<string>();
maxConcurrent = 2;
revokedUrls: string[] = [];
processedReceipts: ProcessedReceipt[] = [];
addFiles(incomingFiles: File[]) {
const existingSignatures = new Set(this.queue.map((i) => `${i.fileName}_${i.fileSizeBytes}`));
for (const file of incomingFiles) {
if (existingSignatures.has(`${file.name}_${file.size}`)) {
continue;
}
const isPdf = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
let previewThumbnailUrl: string | undefined = undefined;
if (!isPdf) {
previewThumbnailUrl = `blob:http://localhost/thumb_${Math.random().toString(36).substring(2)}`;
}
this.queue.push({
id: `upload_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
file,
fileName: file.name || "unnamed_file",
fileSizeBytes: file.size || 0,
mimeType: file.type || (isPdf ? "application/pdf" : "image/jpeg"),
previewThumbnailUrl,
isPdf,
status: "queued",
progressPercent: 0,
stageMessage: "In Warteschlange...",
});
existingSignatures.add(`${file.name}_${file.size}`);
}
}
getStats() {
const totalCount = this.queue.length;
const inProgressCount = this.queue.filter((i) =>
["queued", "preprocessing", "uploading", "extracting"].includes(i.status)
).length;
const successCount = this.queue.filter(
(i) => i.status === "success" || i.status === "duplicate_suspected"
).length;
const errorCount = this.queue.filter((i) => i.status === "error").length;
const aggregatePercent =
totalCount === 0
? 0
: Math.round(
this.queue.reduce((acc, curr) => acc + (curr.progressPercent || 0), 0) / totalCount
);
return { totalCount, inProgressCount, successCount, errorCount, aggregatePercent };
}
async processQueueItem(
itemId: string,
mockScanHandler: (file: File) => Promise<{
success: boolean;
receipts?: ProcessedReceipt[];
receipt?: ProcessedReceipt;
error?: string;
pageCount?: number;
}>
) {
const currentItem = this.queue.find((i) => i.id === itemId);
if (!currentItem) {
this.activeWorkers.delete(itemId);
return;
}
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
if (currentItem.fileSizeBytes > MAX_FILE_SIZE_BYTES) {
currentItem.status = "error";
currentItem.progressPercent = 100;
currentItem.stageMessage = "Datei zu groß";
currentItem.error = "File exceeds the maximum limit of 10.00 MB.";
this.activeWorkers.delete(itemId);
return;
}
// Preprocessing
currentItem.status = "preprocessing";
currentItem.progressPercent = 25;
currentItem.stageMessage = "Vorbereitung & Prüfung...";
try {
// Uploading
currentItem.status = "uploading";
currentItem.progressPercent = 50;
currentItem.stageMessage = "Wird hochgeladen...";
// Extracting
currentItem.status = "extracting";
currentItem.progressPercent = 75;
currentItem.stageMessage = "KI-Vision & Mathe-Prüfung...";
const data = await mockScanHandler(currentItem.file);
if (!data.success) {
throw new Error(data.error || "Serverfehler bei der Belegverarbeitung");
}
const extractedReceipts = data.receipts || (data.receipt ? [data.receipt] : []);
if (extractedReceipts.length === 0) {
throw new Error("No receipt data extracted from this file");
}
for (const r of extractedReceipts) {
this.processedReceipts.push(r);
}
currentItem.status = "success";
currentItem.progressPercent = 100;
currentItem.stageMessage =
data.pageCount && data.pageCount > 1
? `${data.pageCount} Seiten erfasst`
: "Ready";
currentItem.receiptResult = extractedReceipts[0];
currentItem.pageCount = data.pageCount || 1;
} catch (err: any) {
currentItem.status = "error";
currentItem.progressPercent = 100;
currentItem.stageMessage = "Fehlgeschlagen";
currentItem.error = err.message || "Processing failed";
} finally {
this.activeWorkers.delete(itemId);
}
}
async runScheduler(
mockScanHandler: (file: File) => Promise<{
success: boolean;
receipts?: ProcessedReceipt[];
receipt?: ProcessedReceipt;
error?: string;
pageCount?: number;
}>
) {
while (true) {
const queuedItems = this.queue.filter((item) => item.status === "queued");
if (queuedItems.length === 0 && this.activeWorkers.size === 0) break;
const availableSlots = this.maxConcurrent - this.activeWorkers.size;
if (availableSlots <= 0 || queuedItems.length === 0) {
if (this.activeWorkers.size === 0 && queuedItems.length === 0) break;
// Wait briefly for worker slot
await new Promise((res) => setTimeout(res, 2));
continue;
}
const nextBatch = queuedItems.slice(0, availableSlots);
for (const item of nextBatch) {
this.activeWorkers.add(item.id);
}
await Promise.all(nextBatch.map((item) => this.processQueueItem(item.id, mockScanHandler)));
}
}
retryItem(itemId: string) {
const item = this.queue.find((i) => i.id === itemId);
if (item) {
item.status = "queued";
item.progressPercent = 0;
item.error = undefined;
item.stageMessage = "In Warteschlange...";
}
}
removeItem(itemId: string) {
this.activeWorkers.delete(itemId);
const target = this.queue.find((i) => i.id === itemId);
if (target?.previewThumbnailUrl) {
this.revokedUrls.push(target.previewThumbnailUrl);
}
this.queue = this.queue.filter((i) => i.id !== itemId);
}
clearCompleted() {
this.queue = this.queue.filter((item) => {
if (item.status === "success") {
if (item.previewThumbnailUrl) {
this.revokedUrls.push(item.previewThumbnailUrl);
}
return false;
}
return true;
});
}
}
describe("Milestone 1 Challenger 2: Adversarial & Boundary Stress Tests", () => {
let engine: StressQueueEngine;
beforeEach(() => {
engine = new StressQueueEngine();
});
test("ADV-M1.1: Concurrency saturation with 50 files drains completely without worker starvation or deadlock", async () => {
const files: File[] = [];
for (let i = 0; i < 50; i++) {
files.push(createMockFile(`batch_file_${i}.jpg`, 50000 + i * 100, "image/jpeg"));
}
engine.addFiles(files);
expect(engine.queue).toHaveLength(50);
let processedCount = 0;
await engine.runScheduler(async (file) => {
processedCount++;
return {
success: true,
receipt: {
id: `rcpt-${file.name}`,
merchant: { name: `Merchant-${file.name}`, address: null, taxId: null, confidence: 0.95 },
date: { isoDate: "2026-08-15", time: "12:00", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "BON-001",
currency: "EUR",
totalAmount: { value: 10 + processedCount, confidence: 0.99 },
netAmount: 8.40 + processedCount,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.60, netAmount: 8.40 + processedCount }],
lineItems: [{ description: "Item 1", quantity: 1, price: 10 + processedCount, unitPrice: null, taxRate: 19 }],
suggestedCategory: "Sonstiges",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
imageHash: `hash-${file.name}`,
originalFileName: file.name,
fileSizeBytes: file.size,
createdAt: "2026-08-15T12:00:00Z",
updatedAt: "2026-08-15T12:00:00Z",
status: "ready",
},
};
});
const stats = engine.getStats();
expect(stats.totalCount).toBe(50);
expect(stats.successCount).toBe(50);
expect(stats.errorCount).toBe(0);
expect(stats.inProgressCount).toBe(0);
expect(stats.aggregatePercent).toBe(100);
expect(engine.activeWorkers.size).toBe(0);
expect(engine.processedReceipts).toHaveLength(50);
});
test("ADV-M1.2: Boundary file size tests: exactly 10MB passes pre-flight, 10MB + 1 byte fails with error", async () => {
const exact10MB = createMockFile("exact_10mb.jpg", 10 * 1024 * 1024, "image/jpeg");
const over10MB = createMockFile("over_10mb.jpg", 10 * 1024 * 1024 + 1, "image/jpeg");
engine.addFiles([exact10MB, over10MB]);
await engine.runScheduler(async (file) => {
return {
success: true,
receipt: {
id: `rcpt-${file.name}`,
merchant: { name: "Test", address: null, taxId: null, confidence: 0.95 },
date: { isoDate: "2026-08-15", time: null, confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: null,
currency: "EUR",
totalAmount: { value: 10, confidence: 0.99 },
netAmount: 8.40,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.60, netAmount: 8.40 }],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
imageHash: `hash-${file.name}`,
originalFileName: file.name,
fileSizeBytes: file.size,
createdAt: "2026-08-15T12:00:00Z",
updatedAt: "2026-08-15T12:00:00Z",
status: "ready",
},
};
});
const item1 = engine.queue.find((i) => i.fileName === "exact_10mb.jpg");
expect(item1?.status).toBe("success");
expect(item1?.error).toBeUndefined();
const item2 = engine.queue.find((i) => i.fileName === "over_10mb.jpg");
expect(item2?.status).toBe("error");
expect(item2?.error).toContain("10.00 MB");
});
test("ADV-M1.3: Aggregate progress calculation invariant: handles empty queue, partial progress, and all-failed states", () => {
// Empty queue
expect(engine.getStats().aggregatePercent).toBe(0);
// Single item in queued
const file1 = createMockFile("test1.jpg", 1000, "image/jpeg");
engine.addFiles([file1]);
expect(engine.getStats().aggregatePercent).toBe(0);
// Manually test step increments
engine.queue[0].progressPercent = 25;
expect(engine.getStats().aggregatePercent).toBe(25);
engine.queue[0].progressPercent = 50;
expect(engine.getStats().aggregatePercent).toBe(50);
engine.queue[0].progressPercent = 75;
expect(engine.getStats().aggregatePercent).toBe(75);
// Add second item at 0% -> (75 + 0) / 2 = 37.5 -> rounded to 38%
const file2 = createMockFile("test2.jpg", 2000, "image/jpeg");
engine.addFiles([file2]);
expect(engine.getStats().aggregatePercent).toBe(38);
// Both 100% -> 100%
engine.queue[0].progressPercent = 100;
engine.queue[1].progressPercent = 100;
expect(engine.getStats().aggregatePercent).toBe(100);
});
test("ADV-M1.4: Duplicate detection logic: exact hash match produces score 1.0", async () => {
const existingReceipt: ProcessedReceipt = {
id: "rcpt-exist-1",
imageHash: "sha256-abc123def456",
merchant: { name: "Aral Tankstelle", address: "München", taxId: null, confidence: 0.99 },
date: { isoDate: "2026-08-15", time: "10:00", confidence: 0.99 },
documentType: "TANKBELEG",
receiptNumber: "T-1002",
totalAmount: { value: 75.50, confidence: 0.99 },
netAmount: 63.45,
taxBreakdown: [{ ratePercent: 19, taxAmount: 12.05, netAmount: 63.45 }],
lineItems: [],
suggestedCategory: "Tanken & KFZ",
currency: "EUR",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
originalFileName: "aral.jpg",
fileSizeBytes: 120000,
createdAt: "2026-08-15T10:00:00Z",
updatedAt: "2026-08-15T10:00:00Z",
status: "ready",
};
// Test hash matching directly
const candidateHash = "sha256-abc123def456";
const isExactMatch = candidateHash === existingReceipt.imageHash;
expect(isExactMatch).toBe(true);
// Similar test: different hash but same merchant + date + amount
const nameA = "aral tankstelle gmbh".replace(/\b(gmbh|ag|kg|ohg|ug|mbh|co\.?|e\.?k\.?|se|ltd|inc)\b/g, "").replace(/[^a-z0-9äöüß]/g, "").trim();
const nameB = "aral tankstelle".replace(/\b(gmbh|ag|kg|ohg|ug|mbh|co\.?|e\.?k\.?|se|ltd|inc)\b/g, "").replace(/[^a-z0-9äöüß]/g, "").trim();
expect(nameA).toBe(nameB);
});
test("ADV-M1.5: Object URL leak prevention: removed and cleared items trigger URL.revokeObjectURL", () => {
const files = [
createMockFile("image_a.png", 1000, "image/png"),
createMockFile("image_b.png", 2000, "image/png"),
];
engine.addFiles(files);
const urlA = engine.queue[0].previewThumbnailUrl;
const urlB = engine.queue[1].previewThumbnailUrl;
expect(urlA).toBeDefined();
expect(urlB).toBeDefined();
// Remove first item
engine.removeItem(engine.queue[0].id);
expect(engine.revokedUrls).toContain(urlA);
expect(engine.queue).toHaveLength(1);
// Set remaining item to success and clear completed
engine.queue[0].status = "success";
engine.clearCompleted();
expect(engine.revokedUrls).toContain(urlB);
expect(engine.queue).toHaveLength(0);
});
test("ADV-M1.6: Dynamic retry interleaving: retrying a failed item while queue is active does not corrupt queue state", async () => {
const files = [
createMockFile("fail_first.jpg", 1000, "image/jpeg"),
createMockFile("succeed_always.jpg", 2000, "image/jpeg"),
];
engine.addFiles(files);
let failAttempts = 1;
await engine.runScheduler(async (file) => {
if (file.name === "fail_first.jpg" && failAttempts > 0) {
failAttempts--;
return { success: false, error: "Temporary 503 Overloaded" };
}
return {
success: true,
receipt: {
id: `rcpt-${file.name}`,
merchant: { name: "Merchant", address: null, taxId: null, confidence: 0.9 },
date: { isoDate: "2026-08-15", time: null, confidence: 0.9 },
documentType: "KASSENBON",
receiptNumber: null,
currency: "EUR",
totalAmount: { value: 15.0, confidence: 0.95 },
netAmount: 12.61,
taxBreakdown: [{ ratePercent: 19, taxAmount: 2.39, netAmount: 12.61 }],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
imageHash: `hash-${file.name}`,
originalFileName: file.name,
fileSizeBytes: file.size,
createdAt: "2026-08-15T12:00:00Z",
updatedAt: "2026-08-15T12:00:00Z",
status: "ready",
},
};
});
const failedItem = engine.queue.find((i) => i.fileName === "fail_first.jpg");
expect(failedItem?.status).toBe("error");
expect(failedItem?.error).toBe("Temporary 503 Overloaded");
// Retry item
engine.retryItem(failedItem!.id);
expect(failedItem?.status).toBe("queued");
expect(failedItem?.error).toBeUndefined();
// Run scheduler again
await engine.runScheduler(async (file) => {
return {
success: true,
receipt: {
id: `rcpt-${file.name}-retried`,
merchant: { name: "Merchant", address: null, taxId: null, confidence: 0.9 },
date: { isoDate: "2026-08-15", time: null, confidence: 0.9 },
documentType: "KASSENBON",
receiptNumber: null,
currency: "EUR",
totalAmount: { value: 15.0, confidence: 0.95 },
netAmount: 12.61,
taxBreakdown: [{ ratePercent: 19, taxAmount: 2.39, netAmount: 12.61 }],
lineItems: [],
suggestedCategory: "Sonstiges",
validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, reviewField: "none", reviewReason: null },
imageHash: `hash-${file.name}`,
originalFileName: file.name,
fileSizeBytes: file.size,
createdAt: "2026-08-15T12:00:00Z",
updatedAt: "2026-08-15T12:00:00Z",
status: "ready",
},
};
});
expect(failedItem?.status).toBe("success");
expect(engine.getStats().successCount).toBe(2);
expect(engine.getStats().errorCount).toBe(0);
});
test("ADV-M1.7: Duplicate file signatures in single batch or sequential batches are deduplicated by fileName & size", () => {
const file1 = createMockFile("receipt_photo.jpg", 12345, "image/jpeg");
const file1Duplicate = createMockFile("receipt_photo.jpg", 12345, "image/jpeg");
const file2DifferentSize = createMockFile("receipt_photo.jpg", 99999, "image/jpeg");
engine.addFiles([file1, file1Duplicate]);
expect(engine.queue).toHaveLength(1);
engine.addFiles([file2DifferentSize]);
expect(engine.queue).toHaveLength(2);
});
});