Files
craftvia/scripts/test-import-flow.ts
T
msolarczekandClaude Opus 5 3883296300 Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht
- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

Gate: tsc, lint, build, 42/42 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:49:19 +02:00

284 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Lane L3 (Auftragsimport) — service flow against the local DB:
// upload/validation, processing (fake provider), duplicate + site candidates, AiGeneration,
// "kein Auftrag ohne Bestätigung", confirm (existing vs. new customer) incl. document link,
// corrections and audit, double confirm, provider error → failed + retry, no provider → manual,
// discard, tenant isolation (B cannot read/change A) and roles (technician → forbidden).
//
// Lauf: npx tsx scripts/test-import-flow.ts
import "dotenv/config";
import type { WorkOrderExtraction } from "../src/server/ai/providers";
import { dbForTenant, prisma } from "../src/server/db";
import { ROLE_DEFS } from "../src/server/rbac";
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
import { createImport } from "../src/server/services/imports/upload";
import { processImport } from "../src/server/services/imports/process";
import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm";
import { getImportDetail, getImportFile, listImports, searchCustomers } from "../src/server/services/imports/queries";
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
import { emptyExtraction } from "../src/lib/imports/extraction";
import { extractionToForm } from "../src/lib/imports/review";
import { buildPdf } from "./make-sample-pdfs";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function code(p: Promise<unknown>): Promise<string> {
try {
await p;
return "ok";
} catch (e) {
return e instanceof ServiceError ? e.code : `error:${(e as Error).message}`;
}
}
const SLUG_A = "zz-import-a";
const SLUG_B = "zz-import-b";
async function cleanup() {
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (!ids.length) return;
const w = { tenantId: { in: ids } };
await prisma.auditLog.deleteMany({ where: w });
await prisma.aiGeneration.deleteMany({ where: w });
await prisma.materialPlan.deleteMany({ where: w });
await prisma.workOrderStatusChange.deleteMany({ where: w });
await prisma.document.updateMany({ where: w, data: { workOrderId: null, siteId: null, customerId: null } });
await prisma.workOrder.deleteMany({ where: w });
await prisma.importJob.deleteMany({ where: w });
await prisma.document.deleteMany({ where: w });
await prisma.site.deleteMany({ where: w });
await prisma.contact.deleteMany({ where: w });
await prisma.customer.deleteMany({ where: w });
await prisma.numberSequence.deleteMany({ where: w });
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
const ctxFor = (tenantId: string, role: keyof typeof ROLE_DEFS, userId: string): ServiceCtx => ({
db: dbForTenant(tenantId),
tenantId,
userId,
permissions: new Set(ROLE_DEFS[role].permissions),
});
const PDF = buildPdf([[{ text: "Auftragsbestätigung AB-2026-0815" }, { text: "Musterbau GmbH, Hafenstraße 12, 20457 Hamburg" }]]);
const loadBytes = async () => PDF;
const noDispatch = async () => undefined;
function musterbauExtraction(): Partial<WorkOrderExtraction> {
return {
orderNumber: { value: "AB-2026-0815", confidence: 0.97 },
customerNumber: { value: "K-10042", confidence: 0.95 },
companyName: { value: "Musterbau GmbH", confidence: 0.96 },
customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 },
siteName: { value: "Speicherhof", confidence: 0.7, source: "Bauvorhaben: Speicherhof" },
siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
plannedStart: { value: "12.10.2026", confidence: 0.9 },
plannedEnd: { value: "16.10.2026", confidence: 0.9 },
title: { value: "Montage Wärmepumpe", confidence: 0.85 },
positions: {
value: [
{ name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true },
{ name: "Montage", quantity: 8, unit: "Std", isMaterial: false },
],
confidence: 0.88,
},
};
}
async function main() {
await cleanup();
const tA = await prisma.tenant.create({ data: { name: "ZZ Import A", slug: SLUG_A } });
const tB = await prisma.tenant.create({ data: { name: "ZZ Import B", slug: SLUG_B } });
const boA = ctxFor(tA.id, "backoffice", "zz-user-bo-a");
const techA = ctxFor(tA.id, "technician", "zz-user-tech-a");
const leadA = ctxFor(tA.id, "team-lead", "zz-user-lead-a");
const boB = ctxFor(tB.id, "backoffice", "zz-user-bo-b");
// Existing master data in A (duplicate + site candidate).
const existing = await prisma.customer.create({
data: { tenantId: tA.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
});
const existingSite = await prisma.site.create({
data: { tenantId: tA.id, customerId: existing.id, name: "Speicherhof", street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" },
});
await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-99999", companyName: "Andere Firma AG", postalCode: "80331", city: "München" } });
const foreignCustomer = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", postalCode: "20457" } });
// ---------- upload ----------
const dispatched: unknown[] = [];
const job1 = await createImport(boA, { bytes: PDF, fileName: "../AB 2026-0815.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) });
const doc1 = await prisma.document.findUnique({ where: { id: job1.documentId } });
ok(job1.status === "uploaded" && job1.importedById === boA.userId, "(U1) upload creates ImportJob uploaded with importing user");
ok(doc1?.category === "order_confirmation" && doc1.visibility === "backoffice_only" && doc1.mimeType === "application/pdf", "(U2) document category/visibility/type");
ok(doc1?.fileName === "AB 2026-0815.pdf" && doc1.checksum.length === 64, "(U3) file name normalised, SHA-256 stored");
ok(dispatched.length === 1 && (dispatched[0] as { tenantId: string; entityId: string }).entityId === job1.id && (dispatched[0] as { tenantId: string }).tenantId === tA.id, "(U4) extraction job dispatched with tenant + import id");
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import" } })) === 1, "(U5) upload audited");
ok((await code(createImport(boA, { bytes: Buffer.from("MZ not a pdf"), fileName: "x.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U6) wrong magic bytes rejected");
const big = Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(25 * 1024 * 1024)]);
ok((await code(createImport(boA, { bytes: big, fileName: "big.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U7) file > 25 MB rejected");
ok((await code(createImport(boA, { bytes: PDF, fileName: "x.png", mimeType: "image/png" }, { dispatch: noDispatch }))) === "invalid", "(U8) declared type must match content");
ok((await code(createImport(boA, { bytes: PDF, fileName: "x.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, { dispatch: noDispatch }))) === "invalid", "(U9) non-allowlisted type rejected");
// ---------- processing ----------
const provider = new FakeExtractionProvider({ extraction: musterbauExtraction(), text: "Auftragsbestätigung AB-2026-0815 Musterbau GmbH" });
const processed = await processImport(boA, job1.id, { provider, loadBytes, now: new Date("2026-09-14T10:00:00Z") });
ok(processed.status === "review_required", "(X1) processing → review_required");
ok(provider.calls.length === 1 && provider.calls[0].mimeType === "application/pdf", "(X2) provider received the stored PDF");
ok(processed.extractedText?.includes("AB-2026-0815") === true && processed.provider === "fake" && processed.extractionModel === "fake-extraction-1" && processed.extractionVersion !== null, "(X3) text, provider, model and version stored");
const dups = processed.duplicateCandidates as Array<{ customerId: string; score: number; reasons: string[] }>;
ok(dups[0]?.customerId === existing.id && dups[0].reasons.includes("customer_number") && dups[0].reasons.includes("address"), "(X4) existing customer found via number + address (Straße/Strasse)");
ok(!dups.some((d) => d.customerId === foreignCustomer.id), "(X5) duplicate check never returns customers of another tenant");
const stored = processed.extraction as { siteCandidates: Array<{ siteId: string }>; fields: WorkOrderExtraction };
ok(stored.siteCandidates[0]?.siteId === existingSite.id, "(X6) site candidate at same address found");
ok(stored.fields.plannedStart.value === "2026-10-12", "(X7) plausibility normalised dates before storing");
ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job1.id, kind: "import_extraction" } })) === 1, "(X8) AiGeneration recorded");
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 0, "(X9) no work order without confirmation");
const again = await processImport(boA, job1.id, { provider, loadBytes });
ok(again.status === "review_required" && provider.calls.length === 1, "(X10) processing is idempotent");
// ---------- roles ----------
ok((await code(listImports(techA))) === "forbidden", "(R1) technician cannot list imports");
ok((await code(getImportDetail(techA, job1.id))) === "forbidden", "(R2) technician cannot read an import");
ok((await code(getImportFile(leadA, job1.id))) === "forbidden", "(R3) team lead cannot load the original document");
ok((await code(createImport(techA, { bytes: PDF, fileName: "a.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "forbidden", "(R4) technician cannot upload");
ok((await code(confirmImport(techA, job1.id, extractionToForm(stored.fields)))) === "forbidden", "(R5) technician cannot confirm");
ok((await code(discardImport(techA, job1.id))) === "forbidden", "(R6) technician cannot discard");
ok((await code(searchCustomers(techA, "Muster"))) === "forbidden", "(R7) technician cannot use the import customer search");
// ---------- tenant isolation ----------
ok((await code(getImportDetail(boB, job1.id))) === "not_found", "(T1) tenant B cannot read A's import");
ok((await code(getImportFile(boB, job1.id))) === "not_found", "(T2) tenant B cannot load A's document");
ok((await code(confirmImport(boB, job1.id, extractionToForm(stored.fields)))) === "not_found", "(T3) tenant B cannot confirm A's import");
ok((await code(discardImport(boB, job1.id))) === "not_found", "(T4) tenant B cannot discard A's import");
ok((await code(retryImport(boB, job1.id, { dispatch: noDispatch }))) === "not_found", "(T5) tenant B cannot retry A's import");
ok((await code(processImport(boB, job1.id, { provider, loadBytes }))) === "not_found", "(T6) tenant B context cannot process A's import");
ok(!(await listImports(boB)).some((i) => i.id === job1.id), "(T7) A's import not listed for B");
ok(!(await searchCustomers(boB, "Muster")).some((c) => c.id === existing.id), "(T8) customer search stays in tenant");
ok((await prisma.importJob.findUnique({ where: { id: job1.id } }))?.status === "review_required", "(T9) A's import unchanged after B's attempts");
// B's own import may not reference A's customer/site.
const jobB = await createImport(boB, { bytes: PDF, fileName: "b.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
await processImport(boB, jobB.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
const formB = extractionToForm((await getImportDetail(boB, jobB.id)).extraction.fields, { customerId: existing.id, siteId: existingSite.id });
ok((await code(confirmImport(boB, jobB.id, formB))) === "not_found", "(T10) B cannot assign A's customer to its import");
const detailB = await getImportDetail(boB, jobB.id);
ok(detailB.customerCandidates.every((c) => c.customerId === foreignCustomer.id), "(T11) B's candidates only from B");
// ---------- confirm: existing customer ----------
const detail1 = await getImportDetail(boA, job1.id);
ok(detail1.customerCandidates[0]?.customer.companyName === "Musterbau GmbH" && detail1.customerCandidates[0].customer.sites.length === 1, "(K1) detail enriches candidates with customer and sites");
const form1 = extractionToForm(detail1.extraction.fields, { customerId: existing.id, siteId: existingSite.id });
form1.order!.title = "Wärmepumpe Speicherhof montieren";
ok((await code(confirmImport(boA, job1.id, { ...form1, customerId: "" }))) === "invalid", "(K2) invalid form rejected (existing customer without id)");
const customersBefore = await prisma.customer.count({ where: { tenantId: tA.id } });
const res1 = await confirmImport(boA, job1.id, form1);
const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } });
ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site");
ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over");
// The review happens on the ImportJob ("Entwurf – Prüfung erforderlich"); the confirmed order starts planned.
ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "planned", "(K5) confirmed order starts with status history → planned");
ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan");
ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used");
const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } });
ok(docAfter?.workOrderId === wo1?.id && docAfter?.customerId === existing.id && docAfter?.siteId === existingSite.id, "(K8) original document linked to work order, customer and site");
const job1After = await prisma.importJob.findUnique({ where: { id: job1.id } });
const corr = (job1After?.corrections as { fields: Record<string, { from: unknown; to: unknown }> }).fields;
ok(job1After?.status === "confirmed" && job1After.confirmedById === boA.userId && job1After.confirmedAt !== null, "(K9) import confirmed with user and time");
ok(corr["order.title"]?.to === "Wärmepumpe Speicherhof montieren" && corr["order.title"]?.from === "Montage Wärmepumpe", "(K10) corrections stored as diff");
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import", after: { path: ["status"], equals: "confirmed" } } })) === 1, "(K11) confirmation audited as import");
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "work_order", entityId: res1.workOrderId, action: "create" } })) === 1, "(K12) work order creation audited");
ok((await code(confirmImport(boA, job1.id, form1))) === "conflict", "(K13) second confirmation → conflict (no duplicate order)");
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 1, "(K14) still exactly one work order");
ok((await code(discardImport(boA, job1.id))) === "conflict", "(K15) confirmed import cannot be discarded");
// ---------- confirm: new customer ----------
const job2 = await createImport(boA, { bytes: PDF, fileName: "neu.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
const ex2 = {
...musterbauExtraction(),
customerNumber: { value: null, confidence: 0 },
companyName: { value: "Neubau Kraft KG", confidence: 0.94 },
customerAddress: { value: { street: "Ringstraße", houseNumber: "5", postalCode: "22761", city: "Hamburg" }, confidence: 0.9 },
contactName: { value: "Olaf Kraft", confidence: 0.9 },
email: { value: "o.kraft@example.org", confidence: 0.9 },
siteName: { value: "Lagerhalle Nord", confidence: 0.9 },
siteAddress: { value: { street: "Nordkanalstraße", houseNumber: "1", postalCode: "20097", city: "Hamburg" }, confidence: 0.9 },
};
await processImport(boA, job2.id, { provider: new FakeExtractionProvider({ extraction: ex2 }), loadBytes });
const detail2 = await getImportDetail(boA, job2.id);
ok(detail2.customerCandidates.length === 0, "(N1) unknown customer → no duplicate candidates");
const form2 = extractionToForm(detail2.extraction.fields);
ok(form2.customerMode === "new" && form2.siteMode === "new", "(N2) form proposes new customer and new site");
const res2 = await confirmImport(boA, job2.id, form2);
const cust2 = await prisma.customer.findUnique({ where: { id: res2.customerId }, include: { contacts: true, sites: true } });
ok(cust2?.companyName === "Neubau Kraft KG" && cust2.customerNumber?.startsWith("K-") === true && cust2.tenantId === tA.id, "(N3) new customer created in tenant with K- number");
ok(cust2?.contacts[0]?.name === "Olaf Kraft" && res2.contactId === cust2.contacts[0].id, "(N4) contact created");
ok(cust2?.sites[0]?.name === "Lagerhalle Nord" && res2.siteId === cust2.sites[0].id && cust2.sites[0].contactId === res2.contactId, "(N5) site created with contact");
ok(Object.keys((await prisma.importJob.findUnique({ where: { id: job2.id } }))!.corrections as object).length > 0, "(N6) decisions/corrections stored");
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: res2.customerId, action: "create" } })) === 1, "(N7) customer creation audited");
// New customer with a customer number that already exists → conflict, nothing created.
const job3 = await createImport(boA, { bytes: PDF, fileName: "dup.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
await processImport(boA, job3.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
const form3 = extractionToForm((await getImportDetail(boA, job3.id)).extraction.fields);
const woCount = await prisma.workOrder.count({ where: { tenantId: tA.id } });
ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(N8) new customer with taken customer number → conflict");
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === woCount && (await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "review_required", "(N9) nothing created, import stays reviewable");
// ---------- discard ----------
await discardImport(boA, job3.id);
ok((await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "discarded", "(D1) discard → discarded");
ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(D2) discarded import cannot be confirmed");
ok((await prisma.document.findUnique({ where: { id: job3.documentId } })) !== null, "(D3) original document kept after discard");
// ---------- provider failure + retry ----------
const job4 = await createImport(boA, { bytes: PDF, fileName: "fail.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
const failed = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ fail: new Error("Claude API error 529 (OverloadedError)") }), loadBytes });
ok(failed.status === "failed" && failed.errorMessage?.includes("529") === true, "(F1) provider error → failed with message");
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entityId: job4.id, after: { path: ["status"], equals: "failed" } } })) === 1, "(F2) failure audited");
ok((await code(confirmImport(boA, job4.id, form3))) === "conflict", "(F3) failed import cannot be confirmed");
const missing = await createImport(boA, { bytes: PDF, fileName: "missing.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
const noBytes = await processImport(boA, missing.id, { provider, loadBytes: async () => null });
ok(noBytes.status === "failed" && noBytes.errorMessage === "file_unavailable", "(F4) unavailable file → failed (file_unavailable)");
const redispatched: unknown[] = [];
await retryImport(boA, job4.id, { dispatch: async (p) => void redispatched.push(p) });
ok((await prisma.importJob.findUnique({ where: { id: job4.id } }))?.status === "uploaded" && redispatched.length === 1, "(F5) retry → uploaded + dispatched again");
const retried = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
ok(retried.status === "review_required" && retried.errorMessage === null, "(F6) retried processing succeeds");
ok((await code(retryImport(boA, job4.id, { dispatch: noDispatch }))) === "conflict", "(F7) only failed imports can be retried");
const dispatchFail = await createImport(boA, { bytes: PDF, fileName: "q.pdf", mimeType: "application/pdf" }, { dispatch: async () => { throw new Error("redis down"); } });
ok((await prisma.importJob.findUnique({ where: { id: dispatchFail.id } }))?.status === "failed", "(F8) dispatch failure → failed (retryable)");
// ---------- no provider ----------
const job5 = await createImport(boA, { bytes: PDF, fileName: "manual.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
const manual = await processImport(boA, job5.id, { provider: null, loadBytes });
const manualStored = manual.extraction as { hints: Array<{ code: string }>; fields: WorkOrderExtraction };
ok(manual.status === "review_required" && manualStored.hints.some((h) => h.code === "manual_entry"), "(M1) without provider → review_required with manual-entry hint");
const emptyKeys = Object.keys(emptyExtraction());
ok(
emptyKeys.every((k) => {
const f = (manualStored.fields as Record<string, { value: unknown; confidence: number }>)[k];
return f?.value === null && f.confidence === 0;
}) && manual.provider === null,
"(M2) empty extraction, no provider recorded",
);
ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job5.id } })) === 0, "(M3) no AiGeneration without AI use");
await cleanup();
if (failures === 0) console.log("\nOK — Importablauf, Mandantentrennung und Rollen erfüllt.");
else console.log(`\n${failures} FEHLER.`);
await prisma.$disconnect();
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (e) => {
console.error(e);
await cleanup().catch(() => {});
await prisma.$disconnect();
process.exit(1);
});