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>
503 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|