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>
This commit is contained in:
790
tests/spotlight_adversarial.test.ts
Normal file
790
tests/spotlight_adversarial.test.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
/**
|
||||
* Adversarial Spotlight & Interaction Contract Verification Suite
|
||||
* Challenger M1_2 Empirical Harness
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ProcessedReceipt } from "../src/lib/schema/receipt";
|
||||
|
||||
// Simple assertion helper
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
throw new Error(`[ASSERTION FAILED]: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T, message: string) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`[ASSERTION FAILED]: ${message} | Expected: ${expected}, Got: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock test receipt records
|
||||
const mockReceipts: ProcessedReceipt[] = [
|
||||
{
|
||||
id: "rec-001",
|
||||
merchant: { name: "Aral Tankstelle", address: "München", taxId: "DE12345", confidence: 0.99 },
|
||||
date: { isoDate: "2026-08-15", time: "08:42", confidence: 0.98 },
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "ARAL-9988",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 68.45, confidence: 0.99 },
|
||||
netAmount: 57.52,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 10.93, netAmount: 57.52 }],
|
||||
lineItems: [{ description: "Super Plus", quantity: 35.1, unitPrice: 1.95, price: 68.45, taxRate: 19 }],
|
||||
suggestedCategory: "Tanken & KFZ",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
},
|
||||
imageHash: "hash-aral-001",
|
||||
originalFileName: "aral.jpg",
|
||||
fileSizeBytes: 184000,
|
||||
createdAt: "2026-08-15T08:42:00.000Z",
|
||||
updatedAt: "2026-08-15T08:42:00.000Z",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "rec-002",
|
||||
merchant: { name: "Trattoria Bella Vista", address: "Berlin", taxId: "DE98765", confidence: 0.95 },
|
||||
date: { isoDate: "2026-08-14", time: "20:15", confidence: 0.95 },
|
||||
documentType: "BEWIRTUNGSBELEG",
|
||||
receiptNumber: "TRAT-1002",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 84.50, confidence: 0.96 },
|
||||
netAmount: 70.97,
|
||||
taxBreakdown: [
|
||||
{ ratePercent: 7, taxAmount: 3.08, netAmount: 44.0 },
|
||||
{ ratePercent: 19, taxAmount: 10.45, netAmount: 26.97 },
|
||||
],
|
||||
lineItems: [
|
||||
{ description: "Pasta Tartufo", quantity: 2, unitPrice: 22.0, price: 44.0, taxRate: 7 },
|
||||
{ description: "Vino Rosso", quantity: 1, unitPrice: 28.0, price: 28.0, taxRate: 19 },
|
||||
{ description: "Trinkgeld Bewirtung", quantity: 1, unitPrice: 12.5, price: 12.5, taxRate: 0 },
|
||||
],
|
||||
suggestedCategory: "Bewirtung",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
},
|
||||
imageHash: "hash-trattoria-002",
|
||||
originalFileName: "trattoria.jpg",
|
||||
fileSizeBytes: 210000,
|
||||
createdAt: "2026-08-14T20:15:00.000Z",
|
||||
updatedAt: "2026-08-14T20:15:00.000Z",
|
||||
status: "ready",
|
||||
},
|
||||
{
|
||||
id: "rec-003",
|
||||
merchant: { name: "REWE City", address: "Köln", taxId: "DE554433", confidence: 0.94 },
|
||||
date: { isoDate: "2026-08-13", time: "17:30", confidence: 0.95 },
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "REWE-7766",
|
||||
currency: "EUR",
|
||||
totalAmount: { value: 24.80, confidence: 0.95 },
|
||||
netAmount: 22.73,
|
||||
taxBreakdown: [
|
||||
{ ratePercent: 7, taxAmount: 1.31, netAmount: 18.71 },
|
||||
{ ratePercent: 19, taxAmount: 0.76, netAmount: 4.02 },
|
||||
],
|
||||
lineItems: [
|
||||
{ description: "Bio Milch", quantity: 2, unitPrice: 1.49, price: 2.98, taxRate: 7 },
|
||||
{ description: "Dinkelbrot", quantity: 1, unitPrice: 3.49, price: 3.49, taxRate: 7 },
|
||||
{ description: "Küchenrolle", quantity: 1, unitPrice: 4.78, price: 4.78, taxRate: 19 },
|
||||
],
|
||||
suggestedCategory: "Material & Einkauf",
|
||||
validation: {
|
||||
isMathValid: false,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: true,
|
||||
reviewField: "taxBreakdown",
|
||||
reviewReason: "Abweichung",
|
||||
},
|
||||
imageHash: "hash-rewe-003",
|
||||
originalFileName: "rewe.jpg",
|
||||
fileSizeBytes: 165000,
|
||||
createdAt: "2026-08-13T17:30:00.000Z",
|
||||
updatedAt: "2026-08-13T17:30:00.000Z",
|
||||
status: "needs_review",
|
||||
},
|
||||
];
|
||||
|
||||
// Replicate Spotlight Items generation logic for rigorous unit & contract testing
|
||||
function buildCommandItems(options: {
|
||||
isDe: boolean;
|
||||
localReceipts: ProcessedReceipt[];
|
||||
onClose: () => void;
|
||||
onTriggerScan?: () => void;
|
||||
onLoadDemo?: () => void;
|
||||
onSelectReceipt?: (receipt: ProcessedReceipt) => void;
|
||||
routerPush: (path: string) => void;
|
||||
}) {
|
||||
const { isDe, localReceipts, onClose, onTriggerScan, onLoadDemo, onSelectReceipt, routerPush } = options;
|
||||
|
||||
const actionItems = [
|
||||
{
|
||||
id: "action-upload",
|
||||
title: isDe ? "+ Neuen Beleg hochladen / scannen" : "+ Upload / scan new receipt",
|
||||
subtitle: isDe ? "Öffnet Datei-Dialog für Kassenbon-Upload" : "Opens file picker for receipt upload",
|
||||
category: "actions" as const,
|
||||
badge: "SCAN",
|
||||
action: () => {
|
||||
onClose();
|
||||
if (onTriggerScan) {
|
||||
onTriggerScan();
|
||||
} else {
|
||||
routerPush("/dashboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action-demo",
|
||||
title: isDe ? "Demo-Belege laden" : "Load demo receipts",
|
||||
subtitle: isDe ? "Generiert Aral, Rewe & Trattoria Musterbelege" : "Seeds Aral, Rewe & Trattoria sample receipts",
|
||||
category: "actions" as const,
|
||||
badge: "DEMO",
|
||||
action: () => {
|
||||
onClose();
|
||||
if (onLoadDemo) {
|
||||
onLoadDemo();
|
||||
} else {
|
||||
routerPush("/dashboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action-excel",
|
||||
title: isDe ? "Excel-Export (.xlsx) erstellen" : "Generate Excel export (.xlsx)",
|
||||
subtitle: isDe ? "Dual-Sheet Arbeitsmappe mit dynamischen =SUM() Formeln" : "Dual-sheet workbook with dynamic =SUM() formulas",
|
||||
category: "actions" as const,
|
||||
badge: "XLSX",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard/export");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action-csv",
|
||||
title: isDe ? "Buchhaltungs-CSV exportieren" : "Export accounting CSV",
|
||||
subtitle: isDe ? "Semikolon, Dezimalkomma, UTF-8 BOM – öffnet in Excel" : "Semicolon, decimal commas, UTF-8 BOM – opens in Excel",
|
||||
category: "actions" as const,
|
||||
badge: "CSV",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard/export");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const receiptItems = localReceipts.map((r) => ({
|
||||
id: `receipt-${r.id}`,
|
||||
title: `${r.merchant?.name || "Unbekannter Händler"} • ${r.totalAmount?.value ? r.totalAmount.value.toFixed(2) + " €" : ""}`,
|
||||
subtitle: `${r.date?.isoDate || "Kein Datum"} • ${r.suggestedCategory || "Beleg"} ${r.receiptNumber ? "• #" + r.receiptNumber : ""}`,
|
||||
category: "receipts" as const,
|
||||
badge: r.validation?.isMathValid ? "VALID" : "REVIEW",
|
||||
action: () => {
|
||||
onClose();
|
||||
if (onSelectReceipt) {
|
||||
onSelectReceipt(r);
|
||||
} else {
|
||||
routerPush("/dashboard/activity");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: "nav-overview",
|
||||
title: isDe ? "Dashboard Übersicht" : "Dashboard Overview",
|
||||
subtitle: "/dashboard",
|
||||
category: "navigation" as const,
|
||||
badge: "GOTO",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nav-activity",
|
||||
title: isDe ? "Scan-Archiv & Belege" : "Scan Activity & Archive",
|
||||
subtitle: "/dashboard/activity",
|
||||
category: "navigation" as const,
|
||||
badge: "GOTO",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard/activity");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nav-export",
|
||||
title: isDe ? "Export-Zentrale (Excel & CSV)" : "Export Dispatcher (Excel & CSV)",
|
||||
subtitle: "/dashboard/export",
|
||||
category: "navigation" as const,
|
||||
badge: "GOTO",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard/export");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
title: isDe ? "System- & KI-Einstellungen" : "System & AI Settings",
|
||||
subtitle: "/dashboard/settings",
|
||||
category: "navigation" as const,
|
||||
badge: "GOTO",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/dashboard/settings");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nav-landing",
|
||||
title: isDe ? "Landing Page & Hero Scanner" : "Landing Page & Hero Scanner",
|
||||
subtitle: "/",
|
||||
category: "navigation" as const,
|
||||
badge: "HOME",
|
||||
action: () => {
|
||||
onClose();
|
||||
routerPush("/");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return { actionItems, receiptItems, navItems };
|
||||
}
|
||||
|
||||
function filterCommandItems(
|
||||
items: { actionItems: any[]; receiptItems: any[]; navItems: any[] },
|
||||
query: string
|
||||
) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const filteredActions = items.actionItems.filter(
|
||||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||||
);
|
||||
const filteredReceipts = items.receiptItems.filter(
|
||||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||||
);
|
||||
const filteredNav = items.navItems.filter(
|
||||
(item) => item.title.toLowerCase().includes(q) || item.subtitle?.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return {
|
||||
filteredActions,
|
||||
filteredReceipts,
|
||||
filteredNav,
|
||||
filteredItems: [...filteredActions, ...filteredReceipts, ...filteredNav],
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------- TEST SUITE -----------------
|
||||
|
||||
console.log("================================================================");
|
||||
console.log("ADVERSARIAL CHALLENGER M1_2: SPOTLIGHT & INTERACTION CONTRACTS");
|
||||
console.log("================================================================\n");
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function runTest(name: string, fn: () => void) {
|
||||
total++;
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
} catch (err: any) {
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(` ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Keyboard Shortcut Listener Tests (TopNav.tsx & Global contracts)
|
||||
runTest("TopNav keydown: Cmd+k triggers modal toggle on macOS", () => {
|
||||
let isOpen = false;
|
||||
const toggle = () => { isOpen = !isOpen; };
|
||||
|
||||
const eventMac = { metaKey: true, ctrlKey: false, key: "k", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
if ((eventMac.metaKey || eventMac.ctrlKey) && eventMac.key.toLowerCase() === "k") {
|
||||
eventMac.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
|
||||
assertEquals(isOpen, true, "Spotlight should open on metaKey + k");
|
||||
assertEquals(eventMac.defaultPrevented, true, "Default event should be prevented");
|
||||
});
|
||||
|
||||
runTest("TopNav keydown: Ctrl+k triggers modal toggle on Windows/Linux", () => {
|
||||
let isOpen = false;
|
||||
const toggle = () => { isOpen = !isOpen; };
|
||||
|
||||
const eventWin = { metaKey: false, ctrlKey: true, key: "k", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
if ((eventWin.metaKey || eventWin.ctrlKey) && eventWin.key.toLowerCase() === "k") {
|
||||
eventWin.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
|
||||
assertEquals(isOpen, true, "Spotlight should open on ctrlKey + k");
|
||||
assertEquals(eventWin.defaultPrevented, true, "Default event should be prevented");
|
||||
});
|
||||
|
||||
runTest("TopNav keydown: Case-insensitive 'K' (Caps Lock or Shift) is recognized", () => {
|
||||
let isOpen = false;
|
||||
const toggle = () => { isOpen = !isOpen; };
|
||||
|
||||
const eventShift = { metaKey: true, ctrlKey: false, key: "K", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
if ((eventShift.metaKey || eventShift.ctrlKey) && eventShift.key.toLowerCase() === "k") {
|
||||
eventShift.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
|
||||
assertEquals(isOpen, true, "Spotlight should open on metaKey + uppercase K");
|
||||
});
|
||||
|
||||
runTest("TopNav keydown: Unrelated keys (e.g. Cmd+S, Ctrl+P, 'k' alone) do NOT trigger Spotlight", () => {
|
||||
let isOpen = false;
|
||||
const toggle = () => { isOpen = !isOpen; };
|
||||
|
||||
const tests = [
|
||||
{ metaKey: true, ctrlKey: false, key: "s" },
|
||||
{ metaKey: false, ctrlKey: true, key: "p" },
|
||||
{ metaKey: false, ctrlKey: false, key: "k" },
|
||||
{ metaKey: true, ctrlKey: false, key: "Meta" },
|
||||
];
|
||||
|
||||
for (const e of tests) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(isOpen, false, "Spotlight should not open on non-K shortcuts or K without modifier");
|
||||
});
|
||||
|
||||
// 2. SpotlightDialog Arrow Navigation & Selection
|
||||
runTest("SpotlightDialog keydown: ArrowDown navigates cyclically", () => {
|
||||
const items = [1, 2, 3, 4];
|
||||
let selectedIndex = 0;
|
||||
|
||||
const navigateDown = () => {
|
||||
selectedIndex = items.length > 0 ? (selectedIndex + 1) % items.length : 0;
|
||||
};
|
||||
|
||||
navigateDown(); // 1
|
||||
assertEquals(selectedIndex, 1, "Should move to index 1");
|
||||
navigateDown(); // 2
|
||||
assertEquals(selectedIndex, 2, "Should move to index 2");
|
||||
navigateDown(); // 3
|
||||
assertEquals(selectedIndex, 3, "Should move to index 3");
|
||||
navigateDown(); // 0 (wrap-around)
|
||||
assertEquals(selectedIndex, 0, "Should wrap around to index 0");
|
||||
});
|
||||
|
||||
runTest("SpotlightDialog keydown: ArrowUp navigates cyclically backwards", () => {
|
||||
const items = [1, 2, 3, 4];
|
||||
let selectedIndex = 0;
|
||||
|
||||
const navigateUp = () => {
|
||||
selectedIndex = items.length > 0 ? (selectedIndex - 1 + items.length) % items.length : 0;
|
||||
};
|
||||
|
||||
navigateUp(); // 3 (wrap-around backwards)
|
||||
assertEquals(selectedIndex, 3, "Should wrap backwards to index 3");
|
||||
navigateUp(); // 2
|
||||
assertEquals(selectedIndex, 2, "Should move to index 2");
|
||||
navigateUp(); // 1
|
||||
assertEquals(selectedIndex, 1, "Should move to index 1");
|
||||
navigateUp(); // 0
|
||||
assertEquals(selectedIndex, 0, "Should move to index 0");
|
||||
});
|
||||
|
||||
runTest("SpotlightDialog keydown: Arrow navigation with empty results does not throw or return NaN", () => {
|
||||
const items: any[] = [];
|
||||
let selectedIndex = 0;
|
||||
|
||||
const navigateDown = () => {
|
||||
selectedIndex = items.length > 0 ? (selectedIndex + 1) % items.length : 0;
|
||||
};
|
||||
const navigateUp = () => {
|
||||
selectedIndex = items.length > 0 ? (selectedIndex - 1 + items.length) % items.length : 0;
|
||||
};
|
||||
|
||||
navigateDown();
|
||||
assertEquals(selectedIndex, 0, "Empty list down should be 0");
|
||||
navigateUp();
|
||||
assertEquals(selectedIndex, 0, "Empty list up should be 0");
|
||||
assert(!Number.isNaN(selectedIndex), "Index must not be NaN");
|
||||
});
|
||||
|
||||
runTest("SpotlightDialog keydown: Escape triggers onClose", () => {
|
||||
let closed = false;
|
||||
const onClose = () => { closed = true; };
|
||||
|
||||
const eventEsc = { key: "Escape", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
if (eventEsc.key === "Escape") {
|
||||
eventEsc.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
|
||||
assertEquals(closed, true, "Escape must close dialog");
|
||||
assertEquals(eventEsc.defaultPrevented, true, "Escape must prevent default");
|
||||
});
|
||||
|
||||
runTest("SpotlightDialog keydown: Enter invokes action of selected item", () => {
|
||||
let actionExecuted = false;
|
||||
const items = [
|
||||
{ id: "1", action: () => { actionExecuted = false; } },
|
||||
{ id: "2", action: () => { actionExecuted = true; } },
|
||||
];
|
||||
const selectedIndex = 1;
|
||||
|
||||
const eventEnter = { key: "Enter", defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
||||
if (eventEnter.key === "Enter") {
|
||||
eventEnter.preventDefault();
|
||||
if (items[selectedIndex]) {
|
||||
items[selectedIndex].action();
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(actionExecuted, true, "Enter must execute selected item action");
|
||||
assertEquals(eventEnter.defaultPrevented, true, "Enter must prevent default");
|
||||
});
|
||||
|
||||
runTest("SpotlightDialog keydown: Enter with empty list is safely ignored", () => {
|
||||
const items: any[] = [];
|
||||
const selectedIndex = 0;
|
||||
let executed = false;
|
||||
|
||||
if (items[selectedIndex]) {
|
||||
items[selectedIndex].action();
|
||||
executed = true;
|
||||
}
|
||||
|
||||
assertEquals(executed, false, "Enter on empty list should do nothing safely");
|
||||
});
|
||||
|
||||
// 3. Search Filtering Adversarial Cases
|
||||
runTest("Search Filtering: Empty query returns all 4 actions, all receipts, and all 5 navigation links", () => {
|
||||
let closed = false;
|
||||
let pushedRoute = "";
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
const { filteredActions, filteredReceipts, filteredNav, filteredItems } = filterCommandItems(items, "");
|
||||
assertEquals(filteredActions.length, 4, "Should have 4 actions");
|
||||
assertEquals(filteredReceipts.length, 3, "Should have 3 receipts");
|
||||
assertEquals(filteredNav.length, 5, "Should have 5 navigation links");
|
||||
assertEquals(filteredItems.length, 12, "Total items should be 12");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Case-insensitive & whitespace trimmed matching", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
const r1 = filterCommandItems(items, " ARAL ");
|
||||
assertEquals(r1.filteredReceipts.length, 1, "Should find Aral receipt");
|
||||
assertEquals(r1.filteredReceipts[0].title.includes("Aral"), true, "Title must contain Aral");
|
||||
|
||||
const r2 = filterCommandItems(items, "TrAtToRiA");
|
||||
assertEquals(r2.filteredReceipts.length, 1, "Should find Trattoria receipt");
|
||||
|
||||
const r3 = filterCommandItems(items, " rewe ");
|
||||
assertEquals(r3.filteredReceipts.length, 1, "Should find Rewe receipt");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Filter by currency amount and receipt number", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
// Search by amount "68.45"
|
||||
const rAmount = filterCommandItems(items, "68.45");
|
||||
assertEquals(rAmount.filteredReceipts.length, 1, "Should match 68.45 €");
|
||||
assertEquals(rAmount.filteredReceipts[0].id, "receipt-rec-001", "Should be Aral receipt");
|
||||
|
||||
// Search by receipt number "TRAT-1002"
|
||||
const rNum = filterCommandItems(items, "TRAT-1002");
|
||||
assertEquals(rNum.filteredReceipts.length, 1, "Should match receipt number TRAT-1002");
|
||||
assertEquals(rNum.filteredReceipts[0].id, "receipt-rec-002", "Should be Trattoria receipt");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Filter by category tag (e.g. Bewirtung, Tanken)", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
const rCat = filterCommandItems(items, "Bewirtung");
|
||||
assertEquals(rCat.filteredReceipts.length, 1, "Should match Bewirtung category");
|
||||
assertEquals(rCat.filteredReceipts[0].id, "receipt-rec-002", "Should be Trattoria");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Filter by Action names (e.g. 'Excel', 'CSV', 'Demo', 'Upload')", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
// Both export actions legitimately mention Excel (the CSV opens in it), so a
|
||||
// bare "Excel" query must return both — filtering by the format token is what
|
||||
// has to disambiguate them.
|
||||
const rExcel = filterCommandItems(items, "Excel");
|
||||
assertEquals(rExcel.filteredActions.length, 2, "Should find both export actions");
|
||||
|
||||
const rXlsx = filterCommandItems(items, ".xlsx");
|
||||
assertEquals(rXlsx.filteredActions.length, 1, "Should find Excel action");
|
||||
assertEquals(rXlsx.filteredActions[0].id, "action-excel", "ID must be action-excel");
|
||||
|
||||
const rCsv = filterCommandItems(items, "CSV");
|
||||
assertEquals(rCsv.filteredActions.length, 1, "Should find accounting CSV action");
|
||||
assertEquals(rCsv.filteredActions[0].id, "action-csv", "ID must be action-csv");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Filter by navigation path (e.g. '/dashboard/export', 'settings')", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
const rSettings = filterCommandItems(items, "settings");
|
||||
assertEquals(rSettings.filteredNav.length, 1, "Should find settings nav item");
|
||||
assertEquals(rSettings.filteredNav[0].id, "nav-settings", "ID must be nav-settings");
|
||||
|
||||
const rActivity = filterCommandItems(items, "/dashboard/activity");
|
||||
assertEquals(rActivity.filteredNav.length, 1, "Should find activity nav item by route substring");
|
||||
});
|
||||
|
||||
runTest("Search Filtering: Zero-match search returns empty results gracefully", () => {
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
const rNone = filterCommandItems(items, "XYZ999NONEXISTENT");
|
||||
assertEquals(rNone.filteredItems.length, 0, "No items should match");
|
||||
assertEquals(rNone.filteredActions.length, 0, "No actions");
|
||||
assertEquals(rNone.filteredReceipts.length, 0, "No receipts");
|
||||
assertEquals(rNone.filteredNav.length, 0, "No nav");
|
||||
});
|
||||
|
||||
// 4. Action Invocation & Routing Verification
|
||||
runTest("Action Invocation: Quick Action 'upload' calls onClose and custom trigger or router fallback", () => {
|
||||
let closed = false;
|
||||
let scanTriggered = false;
|
||||
let pushedRoute = "";
|
||||
|
||||
const itemsWithTrigger = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
onTriggerScan: () => { scanTriggered = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
itemsWithTrigger.actionItems.find((a) => a.id === "action-upload")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(scanTriggered, true, "onTriggerScan must be called");
|
||||
assertEquals(pushedRoute, "", "routerPush should not be called when onTriggerScan is provided");
|
||||
|
||||
// Test fallback without onTriggerScan
|
||||
closed = false;
|
||||
pushedRoute = "";
|
||||
const itemsWithoutTrigger = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
itemsWithoutTrigger.actionItems.find((a) => a.id === "action-upload")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(pushedRoute, "/dashboard", "routerPush should fallback to /dashboard");
|
||||
});
|
||||
|
||||
runTest("Action Invocation: Quick Action 'demo' calls onClose and custom trigger or router fallback", () => {
|
||||
let closed = false;
|
||||
let demoTriggered = false;
|
||||
let pushedRoute = "";
|
||||
|
||||
const itemsWithDemo = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
onLoadDemo: () => { demoTriggered = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
itemsWithDemo.actionItems.find((a) => a.id === "action-demo")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(demoTriggered, true, "onLoadDemo must be called");
|
||||
|
||||
// Fallback
|
||||
closed = false;
|
||||
const itemsWithoutDemo = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
itemsWithoutDemo.actionItems.find((a) => a.id === "action-demo")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(pushedRoute, "/dashboard", "routerPush should fallback to /dashboard");
|
||||
});
|
||||
|
||||
runTest("Action Invocation: Quick Actions 'excel' and 'csv' route to /dashboard/export", () => {
|
||||
let closed = false;
|
||||
let pushedRoute = "";
|
||||
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
items.actionItems.find((a) => a.id === "action-excel")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(pushedRoute, "/dashboard/export", "Must route to /dashboard/export");
|
||||
|
||||
closed = false;
|
||||
pushedRoute = "";
|
||||
items.actionItems.find((a) => a.id === "action-csv")?.action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(pushedRoute, "/dashboard/export", "Must route to /dashboard/export");
|
||||
});
|
||||
|
||||
runTest("Action Invocation: Receipt item selection calls onSelectReceipt or routes to /dashboard/activity", () => {
|
||||
let closed = false;
|
||||
let selectedReceipt: any = null;
|
||||
let pushedRoute = "";
|
||||
|
||||
const itemsWithSelect = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
onSelectReceipt: (r) => { selectedReceipt = r; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
itemsWithSelect.receiptItems[0].action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(selectedReceipt?.id, "rec-001", "Selected receipt must match rec-001");
|
||||
|
||||
// Fallback
|
||||
closed = false;
|
||||
const itemsWithoutSelect = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
itemsWithoutSelect.receiptItems[0].action();
|
||||
assertEquals(closed, true, "onClose must be called");
|
||||
assertEquals(pushedRoute, "/dashboard/activity", "Must route to /dashboard/activity on fallback");
|
||||
});
|
||||
|
||||
runTest("Action Invocation: Navigation links route to their respective pages", () => {
|
||||
const routes = [
|
||||
{ id: "nav-overview", expected: "/dashboard" },
|
||||
{ id: "nav-activity", expected: "/dashboard/activity" },
|
||||
{ id: "nav-export", expected: "/dashboard/export" },
|
||||
{ id: "nav-settings", expected: "/dashboard/settings" },
|
||||
{ id: "nav-landing", expected: "/" },
|
||||
];
|
||||
|
||||
for (const r of routes) {
|
||||
let closed = false;
|
||||
let pushedRoute = "";
|
||||
const items = buildCommandItems({
|
||||
isDe: true,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => { closed = true; },
|
||||
routerPush: (p) => { pushedRoute = p; },
|
||||
});
|
||||
|
||||
const item = items.navItems.find((n) => n.id === r.id);
|
||||
assert(item !== undefined, `Nav item ${r.id} should exist`);
|
||||
item?.action();
|
||||
assertEquals(closed, true, `onClose must be called for ${r.id}`);
|
||||
assertEquals(pushedRoute, r.expected, `Target path must be ${r.expected}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. English Localization Verification
|
||||
runTest("Localization: English strings render appropriately when language === 'en'", () => {
|
||||
const itemsEn = buildCommandItems({
|
||||
isDe: false,
|
||||
localReceipts: mockReceipts,
|
||||
onClose: () => {},
|
||||
routerPush: () => {},
|
||||
});
|
||||
|
||||
assertEquals(itemsEn.actionItems[0].title.startsWith("+ Upload / scan"), true, "English action title");
|
||||
assertEquals(itemsEn.actionItems[1].title, "Load demo receipts", "English demo title");
|
||||
assertEquals(itemsEn.navItems[0].title, "Dashboard Overview", "English overview nav title");
|
||||
});
|
||||
|
||||
// 6. Source Code Static Token & Lifecycle Invariant Audits
|
||||
runTest("Source Audit: SpotlightDialog.tsx contains zero rounded-, shadow-, or gradient classes", () => {
|
||||
const spotlightCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/SpotlightDialog.tsx"), "utf-8");
|
||||
const forbiddenPatterns = [
|
||||
/\brounded(-[a-z0-9]+)?\b/g,
|
||||
/\bshadow(-[a-z0-9]+)?\b/g,
|
||||
/\bbg-gradient-[a-z0-9-]+\b/g,
|
||||
];
|
||||
|
||||
for (const pattern of forbiddenPatterns) {
|
||||
const matches = spotlightCode.match(pattern);
|
||||
assertEquals(matches, null, `Found forbidden style match ${matches} in SpotlightDialog.tsx`);
|
||||
}
|
||||
});
|
||||
|
||||
runTest("Source Audit: TopNav.tsx contains zero rounded-, shadow-, or gradient classes", () => {
|
||||
const topNavCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/TopNav.tsx"), "utf-8");
|
||||
const forbiddenPatterns = [
|
||||
/\brounded(-[a-z0-9]+)?\b/g,
|
||||
/\bshadow(-[a-z0-9]+)?\b/g,
|
||||
/\bbg-gradient-[a-z0-9-]+\b/g,
|
||||
];
|
||||
|
||||
for (const pattern of forbiddenPatterns) {
|
||||
const matches = topNavCode.match(pattern);
|
||||
assertEquals(matches, null, `Found forbidden style match ${matches} in TopNav.tsx`);
|
||||
}
|
||||
});
|
||||
|
||||
runTest("Source Audit: Both TopNav.tsx and SpotlightDialog.tsx properly unbind keydown event listeners", () => {
|
||||
const topNavCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/TopNav.tsx"), "utf-8");
|
||||
const spotlightCode = fs.readFileSync(path.resolve(__dirname, "../src/components/dashboard/SpotlightDialog.tsx"), "utf-8");
|
||||
|
||||
assert(topNavCode.includes('window.removeEventListener("keydown"'), "TopNav must unbind keydown");
|
||||
assert(spotlightCode.includes('window.removeEventListener("keydown"'), "SpotlightDialog must unbind keydown");
|
||||
});
|
||||
|
||||
console.log("\n================================================================");
|
||||
console.log(`RESULTS: ${passed} / ${total} TESTS PASSED (100% SUCCESS)`);
|
||||
console.log("================================================================\n");
|
||||
Reference in New Issue
Block a user