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:
462
tests/e2e/m3_challenger_deep_stress.test.ts
Normal file
462
tests/e2e/m3_challenger_deep_stress.test.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Milestone 3 (R3) Challenger 2 Deep Adversarial Stress Suite
|
||||
* Empirical Verification of Requirement R3:
|
||||
* 1. Inline Cell Editing & Mathematical Recalculations (0 €, negative, corrupted strings, rapid sequential edits)
|
||||
* 2. Bulk Export Generation (Selected only vs all, CSV injection sanitization, UTF-8 BOM, dual-sheet XLSX integrity)
|
||||
* 3. Bulk Status Updates & Categorization & Deletion
|
||||
* 4. Selection & Filtering Edge Cases & Invariants
|
||||
*/
|
||||
|
||||
import { describe, test, it, expect, beforeEach } from "./runner";
|
||||
import { ProcessedReceipt, ReceiptCategory, PaymentMethod, DocumentType } from "../../src/lib/schema/receipt";
|
||||
import {
|
||||
resolveReceiptStatusTier,
|
||||
getStatusTierMeta,
|
||||
ReceiptStatusTier,
|
||||
} from "../../src/components/dashboard/StatusBadge";
|
||||
import {
|
||||
recalculateReceipt,
|
||||
confirmReceiptReviewed,
|
||||
receiptNeedsAttention,
|
||||
} from "../../src/lib/ai/recalculate";
|
||||
import {
|
||||
parseAmountInput,
|
||||
formatAmountInput,
|
||||
formatMoney,
|
||||
grossOf,
|
||||
netOf,
|
||||
totalTaxOf,
|
||||
taxAmountForRate,
|
||||
collectTaxRates,
|
||||
} from "../../src/components/dashboard/receiptFormat";
|
||||
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
|
||||
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
|
||||
|
||||
function createTestReceipt(overrides: Partial<ProcessedReceipt> = {}): ProcessedReceipt {
|
||||
return {
|
||||
id: "challenger-rcpt-001",
|
||||
imageHash: "hash-ch-123",
|
||||
originalFileName: "sample_receipt.pdf",
|
||||
fileSizeBytes: 150000,
|
||||
previewUrl: "blob:http://localhost/sample.pdf",
|
||||
createdAt: "2026-08-15T12:00:00.000Z",
|
||||
updatedAt: "2026-08-15T12:00:00.000Z",
|
||||
status: "ready",
|
||||
documentType: "KASSENBON",
|
||||
receiptNumber: "RE-9901",
|
||||
currency: "EUR",
|
||||
merchant: {
|
||||
name: "Café Extrablatt GmbH",
|
||||
address: "Alexanderplatz 1, 10178 Berlin",
|
||||
taxId: "DE123456789",
|
||||
confidence: 0.95,
|
||||
},
|
||||
date: {
|
||||
isoDate: "2026-08-15",
|
||||
time: "14:30",
|
||||
confidence: 0.95,
|
||||
},
|
||||
totalAmount: {
|
||||
value: 119.0,
|
||||
confidence: 0.95,
|
||||
},
|
||||
netAmount: 100.0,
|
||||
taxBreakdown: [
|
||||
{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 },
|
||||
],
|
||||
lineItems: [
|
||||
{
|
||||
description: "Espresso Doppio",
|
||||
quantity: 2,
|
||||
unitPrice: 3.5,
|
||||
price: 7.0,
|
||||
taxRate: 19,
|
||||
},
|
||||
{
|
||||
description: "Frühstücksbuffet",
|
||||
quantity: 4,
|
||||
unitPrice: 28.0,
|
||||
price: 112.0,
|
||||
taxRate: 19,
|
||||
},
|
||||
],
|
||||
suggestedCategory: "Bewirtung",
|
||||
paymentMethod: "EC_KARTE",
|
||||
validation: {
|
||||
isMathValid: true,
|
||||
isDuplicateSuspected: false,
|
||||
needsUserReview: false,
|
||||
userConfirmed: false,
|
||||
reviewField: "none",
|
||||
reviewReason: null,
|
||||
issues: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CHALLENGER-M3: Inline Cell Editing & Numeric Parsing Stress", () => {
|
||||
test("CH-M3.1: parseAmountInput parses varied valid and tricky numeric inputs", () => {
|
||||
expect(parseAmountInput("119,00")).toBe(119.0);
|
||||
expect(parseAmountInput("119.00")).toBe(119.0);
|
||||
expect(parseAmountInput("1.234,56 €")).toBe(1234.56);
|
||||
expect(parseAmountInput("1,234.56 EUR")).toBe(1234.56);
|
||||
expect(parseAmountInput("0,00")).toBe(0.0);
|
||||
expect(parseAmountInput("0")).toBe(0.0);
|
||||
expect(parseAmountInput("0.05")).toBe(0.05);
|
||||
expect(parseAmountInput("-50,00")).toBe(-50.0);
|
||||
expect(parseAmountInput("-12.34")).toBe(-12.34);
|
||||
expect(parseAmountInput(" 99,99 ")).toBe(99.99);
|
||||
});
|
||||
|
||||
test("CH-M3.2: parseAmountInput returns null for corrupted strings without crashing or returning NaN", () => {
|
||||
const corrupted = [
|
||||
"abc",
|
||||
"",
|
||||
" ",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"€€€",
|
||||
"EUR",
|
||||
"foo-bar-123",
|
||||
"12.34.56.78",
|
||||
",,,",
|
||||
"... ",
|
||||
"$$$123",
|
||||
"[object Object]",
|
||||
"undefined",
|
||||
"null",
|
||||
];
|
||||
|
||||
for (const val of corrupted) {
|
||||
const parsed = parseAmountInput(val);
|
||||
if (parsed !== null) {
|
||||
expect(Number.isFinite(parsed)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("CH-M3.3: Editing Gross Amount to 0.00 € recalculates Net and Tax to 0.00 without division by zero", () => {
|
||||
const rcpt = createTestReceipt({
|
||||
totalAmount: { value: 119.0, confidence: 0.9 },
|
||||
netAmount: 100.0,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...rcpt,
|
||||
totalAmount: { ...rcpt.totalAmount, value: 0.0 },
|
||||
};
|
||||
|
||||
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(result.totalAmount.value).toBe(0.0);
|
||||
expect(result.netAmount).toBe(0.0);
|
||||
expect(result.taxBreakdown[0].taxAmount).toBe(0.0);
|
||||
expect(result.taxBreakdown[0].netAmount).toBe(0.0);
|
||||
expect(Number.isNaN(result.netAmount ?? 0)).toBe(false);
|
||||
expect(Number.isFinite(result.netAmount ?? 0)).toBe(true);
|
||||
expect(result.validation.isMathValid).toBe(true);
|
||||
});
|
||||
|
||||
test("CH-M3.4: Editing Gross Amount to negative value (refund / credit note) distributes proportionally", () => {
|
||||
const rcpt = createTestReceipt({
|
||||
totalAmount: { value: 119.0, confidence: 0.9 },
|
||||
netAmount: 100.0,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
|
||||
lineItems: [], // No positive line items
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...rcpt,
|
||||
totalAmount: { ...rcpt.totalAmount, value: -119.0 },
|
||||
};
|
||||
|
||||
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(result.totalAmount.value).toBe(-119.0);
|
||||
expect(result.netAmount).toBe(-100.0);
|
||||
expect(result.taxBreakdown[0].taxAmount).toBe(-19.0);
|
||||
expect(result.taxBreakdown[0].netAmount).toBe(-100.0);
|
||||
// Net + tax sum precisely equals negative gross (-100 + -19 = -119)
|
||||
expect((result.netAmount ?? 0) + (result.taxBreakdown[0]?.taxAmount ?? 0)).toBe(-119.0);
|
||||
});
|
||||
|
||||
test("CH-M3.5: Multi-tax rate gross recalculation with 19%, 7%, and 0% taxes", () => {
|
||||
const multiTax = createTestReceipt({
|
||||
totalAmount: { value: 126.0, confidence: 0.9 },
|
||||
netAmount: 110.0,
|
||||
taxBreakdown: [
|
||||
{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 },
|
||||
{ ratePercent: 7, taxAmount: 0.7, netAmount: 10.0 },
|
||||
{ ratePercent: 0, taxAmount: 0.0, netAmount: 0.0 },
|
||||
],
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...multiTax,
|
||||
totalAmount: { ...multiTax.totalAmount, value: 252.0 },
|
||||
};
|
||||
|
||||
const result = recalculateReceipt(updated, { editedField: "totalAmount" });
|
||||
expect(result.totalAmount.value).toBe(252.0);
|
||||
// Gross doubled: 126 -> 252. Net and tax should double accordingly
|
||||
const sumTaxes = totalTaxOf(result);
|
||||
const net = netOf(result);
|
||||
expect(Math.round((net + sumTaxes) * 100) / 100).toBe(252.0);
|
||||
expect(result.validation.isMathValid).toBe(true);
|
||||
});
|
||||
|
||||
test("CH-M3.6: Editing Net Amount holds Gross constant and re-derives taxes", () => {
|
||||
const rcpt = createTestReceipt({
|
||||
totalAmount: { value: 119.0, confidence: 0.9 },
|
||||
netAmount: 100.0,
|
||||
taxBreakdown: [{ ratePercent: 19, taxAmount: 19.0, netAmount: 100.0 }],
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...rcpt,
|
||||
netAmount: 90.0,
|
||||
};
|
||||
|
||||
const result = recalculateReceipt(updated, { editedField: "netAmount" });
|
||||
expect(result.totalAmount.value).toBe(119.0);
|
||||
expect(result.netAmount).toBe(90.0);
|
||||
expect(result.taxBreakdown[0].taxAmount).toBe(29.0); // 119 - 90 = 29
|
||||
expect(result.taxBreakdown[0].netAmount).toBe(90.0);
|
||||
});
|
||||
|
||||
test("CH-M3.7: Rapid sequential edits across multiple fields preserve integrity", () => {
|
||||
let rcpt = createTestReceipt();
|
||||
|
||||
// 1. Edit Merchant
|
||||
rcpt = recalculateReceipt(
|
||||
{
|
||||
...rcpt,
|
||||
merchant: { ...rcpt.merchant, name: "Neue Gastronomie Berlin" },
|
||||
editedFields: { merchant: true },
|
||||
},
|
||||
{ editedField: "merchant" }
|
||||
);
|
||||
expect(rcpt.merchant.name).toBe("Neue Gastronomie Berlin");
|
||||
expect(rcpt.merchant.confidence).toBe(1.0);
|
||||
|
||||
// 2. Edit Date
|
||||
rcpt = recalculateReceipt(
|
||||
{
|
||||
...rcpt,
|
||||
date: { ...rcpt.date, isoDate: "2026-08-01" },
|
||||
editedFields: { ...rcpt.editedFields, date: true },
|
||||
},
|
||||
{ editedField: "date" }
|
||||
);
|
||||
expect(rcpt.date.isoDate).toBe("2026-08-01");
|
||||
expect(rcpt.date.confidence).toBe(1.0);
|
||||
|
||||
// 3. Edit Gross
|
||||
rcpt = recalculateReceipt(
|
||||
{
|
||||
...rcpt,
|
||||
totalAmount: { ...rcpt.totalAmount, value: 595.0 },
|
||||
editedFields: { ...rcpt.editedFields, totalAmount: true },
|
||||
},
|
||||
{ editedField: "totalAmount" }
|
||||
);
|
||||
expect(rcpt.totalAmount.value).toBe(595.0);
|
||||
expect(rcpt.netAmount).toBe(500.0);
|
||||
expect(rcpt.taxBreakdown[0].taxAmount).toBe(95.0);
|
||||
expect(rcpt.totalAmount.confidence).toBe(1.0);
|
||||
|
||||
// 4. Edit Category
|
||||
rcpt = recalculateReceipt(
|
||||
{
|
||||
...rcpt,
|
||||
suggestedCategory: "Reisekosten & Hotel",
|
||||
editedFields: { ...rcpt.editedFields, category: true },
|
||||
},
|
||||
{ editedField: "category" }
|
||||
);
|
||||
expect(rcpt.suggestedCategory).toBe("Reisekosten & Hotel");
|
||||
|
||||
expect(rcpt.validation.isMathValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CHALLENGER-M3: Bulk Export Generation & Security Sanitization", () => {
|
||||
const dataset: ProcessedReceipt[] = [
|
||||
createTestReceipt({
|
||||
id: "exp-1",
|
||||
merchant: { name: 'Firma "Test & Co." GmbH', address: "München", taxId: "DE1", confidence: 1.0 },
|
||||
receiptNumber: "INV-001",
|
||||
totalAmount: { value: 119.0, confidence: 1.0 },
|
||||
suggestedCategory: "Bürobedarf & IT",
|
||||
}),
|
||||
createTestReceipt({
|
||||
id: "exp-2",
|
||||
merchant: { name: "=1+1; -- Formula Injection Test", address: "Frankfurt", taxId: "DE2", confidence: 1.0 },
|
||||
receiptNumber: "INV-002",
|
||||
totalAmount: { value: 53.5, confidence: 1.0 },
|
||||
netAmount: 50.0,
|
||||
taxBreakdown: [{ ratePercent: 7, taxAmount: 3.5, netAmount: 50.0 }],
|
||||
suggestedCategory: "Bewirtung",
|
||||
hospitality: { occasion: "Kundengespräch & Akquise", participants: "Max Mustermann, Erika Musterfrau" },
|
||||
}),
|
||||
createTestReceipt({
|
||||
id: "exp-3",
|
||||
merchant: { name: "@SUM(A1:A100) \n Multiline \r\n Carriage", address: "Hamburg", taxId: "DE3", confidence: 1.0 },
|
||||
receiptNumber: "INV-003",
|
||||
totalAmount: { value: 200.0, confidence: 1.0 },
|
||||
suggestedCategory: "Tanken & KFZ",
|
||||
}),
|
||||
];
|
||||
|
||||
test("CH-M3.8: Bulk export for SELECTED items only excludes unselected records", async () => {
|
||||
const selectedIds = ["exp-1", "exp-3"];
|
||||
const selectedReceipts = dataset.filter((r) => selectedIds.includes(r.id));
|
||||
|
||||
expect(selectedReceipts).toHaveLength(2);
|
||||
expect(selectedReceipts.some((r) => r.id === "exp-2")).toBe(false);
|
||||
|
||||
// CSV
|
||||
const csv = generateAccountingCsv(selectedReceipts);
|
||||
expect(csv).toContain("INV-001");
|
||||
expect(csv).toContain("INV-003");
|
||||
expect(csv.includes("INV-002")).toBe(false);
|
||||
|
||||
// Excel
|
||||
const xlsxBuffer = await generateDualSheetExcel(selectedReceipts);
|
||||
expect(xlsxBuffer).toBeDefined();
|
||||
expect(xlsxBuffer.length).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
test("CH-M3.9: Bulk export with EMPTY list handles cleanly without throwing", async () => {
|
||||
const emptyCsv = generateAccountingCsv([]);
|
||||
expect(emptyCsv).toBeDefined();
|
||||
expect(emptyCsv.charCodeAt(0)).toBe(0xfeff); // UTF-8 BOM
|
||||
expect(emptyCsv).toContain("Laufende Nr");
|
||||
|
||||
const emptyXlsx = await generateDualSheetExcel([]);
|
||||
expect(emptyXlsx).toBeDefined();
|
||||
expect(emptyXlsx.length).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
test("CH-M3.10: Accounting CSV properly quotes and escapes semicolons, quotes, and newlines", () => {
|
||||
const csv = generateAccountingCsv(dataset);
|
||||
|
||||
// Must start with UTF-8 BOM
|
||||
expect(csv.charCodeAt(0)).toBe(0xfeff);
|
||||
|
||||
// Double quotes must be escaped as ""
|
||||
expect(csv).toContain('""Test & Co.""');
|
||||
|
||||
// Formula-injection payloads (=, +, - @, tab, CR) are neutralized with a
|
||||
// leading apostrophe (OWASP CSV-injection mitigation) before quoting, so
|
||||
// the cell is exported as safe text, never as an evaluable formula
|
||||
expect(csv).toContain("'=1+1; -- Formula Injection Test\"");
|
||||
|
||||
// Hospitality details must be exported when present
|
||||
expect(csv).toContain("Kundengespräch & Akquise");
|
||||
expect(csv).toContain("Max Mustermann, Erika Musterfrau");
|
||||
});
|
||||
|
||||
test("CH-M3.11: Dual-Sheet Excel generates both Belegübersicht and Einzelpositionen Detail sheets", async () => {
|
||||
const buffer = await generateDualSheetExcel(dataset);
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(buffer.length).toBeGreaterThan(5000);
|
||||
|
||||
// Verify it's a valid ZIP / XLSX header (PK\x03\x04)
|
||||
expect(buffer[0]).toBe(0x50); // 'P'
|
||||
expect(buffer[1]).toBe(0x4b); // 'K'
|
||||
expect(buffer[2]).toBe(0x03);
|
||||
expect(buffer[3]).toBe(0x04);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CHALLENGER-M3: Bulk Status Updates & Batch Operations", () => {
|
||||
const mockBatch = [
|
||||
createTestReceipt({ id: "b1", status: "needs_review", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, userConfirmed: false, reviewField: "taxBreakdown", reviewReason: "Diff", issues: [{ field: "taxBreakdown", severity: "error", message: "Diff" }] } }),
|
||||
createTestReceipt({ id: "b2", status: "ready", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: false, reviewField: "none", reviewReason: null, issues: [] } }),
|
||||
createTestReceipt({ id: "b3", status: "ready", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: false, reviewField: "none", reviewReason: null, issues: [] } }),
|
||||
];
|
||||
|
||||
test("CH-M3.12: Bulk Status Update to 'confirmed' verifies all selected items and clears review flags", () => {
|
||||
const selectedIds = ["b1", "b2"];
|
||||
const updatedBatch = mockBatch.map((r) =>
|
||||
selectedIds.includes(r.id) ? confirmReceiptReviewed(r) : r
|
||||
);
|
||||
|
||||
const b1Updated = updatedBatch.find((r) => r.id === "b1");
|
||||
const b2Updated = updatedBatch.find((r) => r.id === "b2");
|
||||
const b3Updated = updatedBatch.find((r) => r.id === "b3");
|
||||
|
||||
expect(b1Updated?.validation.userConfirmed).toBe(true);
|
||||
expect(b1Updated?.validation.needsUserReview).toBe(false);
|
||||
expect(resolveReceiptStatusTier(b1Updated!)).toBe("confirmed");
|
||||
|
||||
expect(b2Updated?.validation.userConfirmed).toBe(true);
|
||||
expect(resolveReceiptStatusTier(b2Updated!)).toBe("confirmed");
|
||||
|
||||
expect(b3Updated?.validation.userConfirmed).toBe(false);
|
||||
expect(resolveReceiptStatusTier(b3Updated!)).toBe("scanned");
|
||||
});
|
||||
|
||||
test("CH-M3.13: Bulk Categorize updates category and marks editedFields", () => {
|
||||
const selectedIds = ["b1", "b3"];
|
||||
const targetCategory: ReceiptCategory = "Tanken & KFZ";
|
||||
|
||||
const updatedBatch = mockBatch.map((r) => {
|
||||
if (selectedIds.includes(r.id)) {
|
||||
const withCat = {
|
||||
...r,
|
||||
suggestedCategory: targetCategory,
|
||||
editedFields: { ...(r.editedFields || {}), category: true },
|
||||
};
|
||||
return recalculateReceipt(withCat, { editedField: "category" });
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
expect(updatedBatch.find((r) => r.id === "b1")?.suggestedCategory).toBe("Tanken & KFZ");
|
||||
expect(updatedBatch.find((r) => r.id === "b2")?.suggestedCategory).toBe("Bewirtung");
|
||||
expect(updatedBatch.find((r) => r.id === "b3")?.suggestedCategory).toBe("Tanken & KFZ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CHALLENGER-M3: Filter & Selection Mathematical Invariants", () => {
|
||||
const testList: ProcessedReceipt[] = [
|
||||
createTestReceipt({ id: "t1", date: { isoDate: "2026-08-15", time: "10:00", confidence: 1.0 }, totalAmount: { value: 25.0, confidence: 1.0 }, suggestedCategory: "Bewirtung" }),
|
||||
createTestReceipt({ id: "t2", date: { isoDate: "2026-08-14", time: "12:00", confidence: 1.0 }, totalAmount: { value: 75.0, confidence: 1.0 }, suggestedCategory: "Tanken & KFZ", validation: { isMathValid: false, isDuplicateSuspected: false, needsUserReview: true, userConfirmed: false, reviewField: "taxBreakdown", reviewReason: "Diff", issues: [] } }),
|
||||
createTestReceipt({ id: "t3", date: { isoDate: "2026-07-01", time: "09:00", confidence: 1.0 }, totalAmount: { value: 350.0, confidence: 1.0 }, suggestedCategory: "Bürobedarf & IT", validation: { isMathValid: true, isDuplicateSuspected: false, needsUserReview: false, userConfirmed: true, reviewField: "none", reviewReason: null, issues: [] } }),
|
||||
createTestReceipt({ id: "t4", date: { isoDate: "2026-01-10", time: "15:00", confidence: 1.0 }, totalAmount: { value: 12.0, confidence: 1.0 }, suggestedCategory: "Sonstiges" }),
|
||||
];
|
||||
|
||||
test("CH-M3.14: Status count partition invariant holds (all === scanned + pending + confirmed)", () => {
|
||||
let scanned = 0;
|
||||
let pending = 0;
|
||||
let confirmed = 0;
|
||||
|
||||
for (const r of testList) {
|
||||
const tier = resolveReceiptStatusTier(r);
|
||||
if (tier === "pending_review") pending++;
|
||||
else if (tier === "confirmed") confirmed++;
|
||||
else scanned++;
|
||||
}
|
||||
|
||||
expect(scanned + pending + confirmed).toBe(testList.length);
|
||||
expect(scanned).toBe(2);
|
||||
expect(pending).toBe(1);
|
||||
expect(confirmed).toBe(1);
|
||||
});
|
||||
|
||||
test("CH-M3.15: Category collection and tax rate collection are deterministic sets", () => {
|
||||
const taxRates = collectTaxRates(testList);
|
||||
expect(Array.isArray(taxRates)).toBe(true);
|
||||
expect(taxRates).toContain(19);
|
||||
|
||||
const categories = Array.from(new Set(testList.map((r) => r.suggestedCategory)));
|
||||
expect(categories).toHaveLength(4);
|
||||
expect(categories).toContain("Bewirtung");
|
||||
expect(categories).toContain("Tanken & KFZ");
|
||||
expect(categories).toContain("Bürobedarf & IT");
|
||||
expect(categories).toContain("Sonstiges");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user