From 565cd4ef5c499b5552f62ef372cba4be71ed4f90 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:43:47 +0200 Subject: [PATCH] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20Auth?= =?UTF-8?q?entifizierter=20Durchstich=20aller=20Kernseiten=20je=20Rolle,?= =?UTF-8?q?=20Last-Seed=20(5=20000=20Auftr=C3=A4ge)=20mit=20Performance-Me?= =?UTF-8?q?ssung,=20Demo-Import=20f=C3=BCr=20die=20Pr=C3=BCfmaske?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/lib/demo-seed.ts | 31 +++++ scripts/seed-load.ts | 159 +++++++++++++++++++++++++ scripts/smoke-auth.ts | 246 ++++++++++++++++++++++++++++++++------- 3 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 scripts/seed-load.ts diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts index 768bc75..995ee55 100644 --- a/scripts/lib/demo-seed.ts +++ b/scripts/lib/demo-seed.ts @@ -8,7 +8,13 @@ // Alle Namen, Adressen und Telefonnummern sind frei erfunden (keine echten personenbezogenen Daten). import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import sharp from "sharp"; +import { FakeExtractionProvider } from "../../src/server/ai/extraction/fake"; +import { createImport } from "../../src/server/services/imports/upload"; +import { processImport } from "../../src/server/services/imports/process"; +import { buildPdf } from "../make-sample-pdfs"; import { prisma, dbForTenant } from "../../src/server/db"; import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; import type { ServiceCtx } from "../../src/server/services/context"; @@ -387,6 +393,31 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis created++; } + // Import in Prüfung (Prüfmaske): Musterdokument + Extraktion über den Fake-Provider (keine KI nötig) + if (!(await bo.db.importJob.findFirst({ where: { extractedText: { startsWith: "DEMO-IMPORT" } }, select: { id: true } }))) { + const samplePath = join(process.cwd(), "docs", "craftvia", "samples", "02-elbblick-wartungsauftrag.pdf"); + const bytes = existsSync(samplePath) ? readFileSync(samplePath) : buildPdf([[{ text: "Wartungsauftrag Hausverwaltung Elbblick GmbH" }]]); + const job = await createImport(bo, { bytes, fileName: "wartungsauftrag-elbblick.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); + await processImport(bo, job.id, { + provider: new FakeExtractionProvider({ + text: "DEMO-IMPORT Wartungsauftrag Hausverwaltung Elbblick GmbH, Große Elbstraße 140, 22767 Hamburg", + extraction: { + orderNumber: { value: "WA-2026-117", confidence: 0.96 }, + customerNumber: { value: "K-D001", confidence: 0.91 }, + companyName: { value: "Hausverwaltung Elbblick GmbH", confidence: 0.95 }, + customerAddress: { value: { street: "Große Elbstr.", houseNumber: "140", postalCode: "22767", city: "Hamburg", country: "DE" }, confidence: 0.9 }, + siteName: { value: "Wohnanlage Elbblick – Haus B", confidence: 0.72, source: "Objekt: Haus B" }, + siteAddress: { value: { street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg" }, confidence: 0.88 }, + contactName: { value: "Frauke Albers", confidence: 0.83 }, + title: { value: "Wartung Warmwasserbereitung", confidence: 0.86 }, + plannedStart: { value: localDateKey(day(12), TZ), confidence: 0.64, source: "KW-Angabe unsicher" }, + positions: { value: [{ name: "Wartungsset Speicher", articleNumber: "WS-S2", quantity: 1, unit: "Satz", isMaterial: true }, { name: "Arbeitszeit", quantity: 2, unit: "Std", isMaterial: false }], confidence: 0.8 }, + }, + }), + loadBytes: async () => bytes, + }); + } + const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); return { orders, created }; } diff --git a/scripts/seed-load.ts b/scripts/seed-load.ts new file mode 100644 index 0000000..69ba19c --- /dev/null +++ b/scripts/seed-load.ts @@ -0,0 +1,159 @@ +/** + * L10a Last-Seed (Spec §34.1): füllt den Demo-Mandanten mit N Aufträgen (Default 5 000) für + * Performance-Messungen der Auftragsliste und des Dashboards. + * + * npx tsx scripts/seed-load.ts # 5 000 Aufträge (idempotent: fehlende werden ergänzt) + * LOAD_ORDERS=20000 npx tsx scripts/seed-load.ts + * npx tsx scripts/seed-load.ts --reset # Last-Daten wieder entfernen + * + * Datenbild: 250 Kunden mit je einem Objekt, Aufträge über alle Status verteilt (Schwerpunkt offen), + * Termine −90…+60 Tage, Teams Nord/Süd mit Einzelzuweisungen, je Auftrag 2 Materialvorgaben, jeder + * zehnte mit Statushistorie und eingereichtem Bericht. Schnell über createMany (Owner-Client, nur + * Testdaten) — fachliche Abläufe testet scripts/test-e2e-*.ts. Kennzeichen: Nummer `L-…`, + * externe Auftragsnummer `LOAD-…`, Kundennummer `K-L…`. + * + * Messung danach: BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts + */ +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma, PrismaClient, type WorkOrderStatus } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }); +const TARGET = Number(process.env.LOAD_ORDERS ?? 5000); +const CUSTOMERS = 250; +const CHUNK = 1000; + +// weighted status mix (open work dominates a real tenant) +const STATUS_MIX: [WorkOrderStatus, number][] = [ + ["draft", 3], ["review_required", 2], ["planned", 10], ["assigned", 12], ["accepted", 6], ["en_route", 2], + ["in_progress", 8], ["paused", 2], ["waiting_material", 2], ["daily_report_created", 2], ["technically_completed", 2], + ["signature_pending", 2], ["in_review", 5], ["released_for_billing", 8], ["billed", 30], ["cancelled", 4], +]; +const WEIGHT = STATUS_MIX.reduce((s, [, w]) => s + w, 0); + +function statusFor(i: number): WorkOrderStatus { + let x = (i * 7919) % WEIGHT; + for (const [s, w] of STATUS_MIX) { + if (x < w) return s; + x -= w; + } + return "planned"; +} + +async function reset(tenantId: string) { + const orders = await prisma.workOrder.findMany({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } }, select: { id: true } }); + const ids = orders.map((o) => o.id); + for (let i = 0; i < ids.length; i += CHUNK) { + const w = { workOrderId: { in: ids.slice(i, i + CHUNK) } }; + await prisma.report.deleteMany({ where: w }); + await prisma.workOrderStatusChange.deleteMany({ where: w }); + await prisma.workOrder.deleteMany({ where: { id: { in: ids.slice(i, i + CHUNK) } } }); + } + await prisma.site.deleteMany({ where: { tenantId, customer: { customerNumber: { startsWith: "K-L" } } } }); + const c = await prisma.customer.deleteMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } } }); + console.log(`✔ Last-Daten entfernt: ${ids.length} Aufträge, ${c.count} Kunden`); +} + +async function main() { + const tenant = await prisma.tenant.findUnique({ where: { slug: "demo" } }); + if (!tenant) throw new Error('Mandant "demo" fehlt — zuerst `npx prisma db seed`.'); + const tenantId = tenant.id; + if (process.argv.includes("--reset")) return reset(tenantId); + + const teams = await prisma.team.findMany({ where: { tenantId, name: { in: ["Team Nord", "Team Süd"] } }, include: { members: true } }); + if (teams.length < 2) throw new Error("Demo-Teams fehlen — Seed mit Demo-Daten ausführen (SEED_DEMO)."); + const orderTypes = await prisma.orderType.findMany({ where: { tenantId }, select: { id: true } }); + const creator = await prisma.user.findFirstOrThrow({ where: { tenantId, email: "backoffice@demo.example" } }); + + // customers + sites + const existingCustomers = await prisma.customer.count({ where: { tenantId, customerNumber: { startsWith: "K-L" } } }); + if (existingCustomers < CUSTOMERS) { + const rows = Array.from({ length: CUSTOMERS }, (_, i) => ({ + id: randomUUID(), + tenantId, + customerNumber: `K-L${String(i + 1).padStart(4, "0")}`, + companyName: `Lastkunde ${String(i + 1).padStart(3, "0")} GmbH`, + street: "Lastweg", + houseNumber: String(i + 1), + postalCode: String(20000 + (i % 900)), + city: i % 2 ? "Hamburg" : "Lübeck", + })); + await prisma.customer.createMany({ data: rows, skipDuplicates: true }); + const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" }, sites: { none: {} } }, select: { id: true, customerNumber: true } }); + await prisma.site.createMany({ data: customers.map((c) => ({ tenantId, customerId: c.id, name: `Objekt ${c.customerNumber}`, street: "Lastweg", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" })) }); + } + const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } }, select: { id: true, sites: { select: { id: true }, take: 1 } }, orderBy: { customerNumber: "asc" } }); + + const existing = await prisma.workOrder.count({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } } }); + const missing = Math.max(0, TARGET - existing); + console.log(`Mandant demo: ${existing} Last-Aufträge vorhanden, lege ${missing} an …`); + const t0 = Date.now(); + const day = 86400_000; + for (let offset = existing; offset < existing + missing; offset += CHUNK) { + const n = Math.min(CHUNK, existing + missing - offset); + const orders: Prisma.WorkOrderCreateManyInput[] = []; + const assignees: Prisma.WorkOrderAssigneeCreateManyInput[] = []; + const plans: Prisma.MaterialPlanCreateManyInput[] = []; + const history: Prisma.WorkOrderStatusChangeCreateManyInput[] = []; + const reports: Prisma.ReportCreateManyInput[] = []; + for (let k = 0; k < n; k++) { + const i = offset + k; + const status = statusFor(i); + const team = teams[i % 2]; + const customer = customers[i % customers.length]; + const start = new Date(Date.now() + (((i * 37) % 150) - 90) * day + ((i % 9) + 7) * 3600_000); + const id = randomUUID(); + const planned = !["draft", "review_required"].includes(status); + orders.push({ + id, + tenantId, + number: `L-${String(i + 1).padStart(6, "0")}`, + externalOrderNumber: `LOAD-${i + 1}`, + customerId: customer.id, + siteId: customer.sites[0]?.id ?? null, + orderTypeId: orderTypes.length ? orderTypes[i % orderTypes.length].id : null, + priority: i % 17 === 0 ? "urgent" : i % 5 === 0 ? "high" : "normal", + status, + title: `Lastauftrag ${i + 1}: ${["Wartung Heizung", "Reparatur Leitung", "Montage Therme", "Störung Lüftung"][i % 4]}`, + description: "Automatisch erzeugter Auftrag für Lasttests.", + plannedStart: start, + plannedEnd: new Date(start.getTime() + 4 * 3600_000), + assignedTeamId: planned ? team.id : null, + teamLeadUserId: planned ? team.leaderUserId : null, + isEmergency: i % 97 === 0, + createdById: creator.id, + createdAt: new Date(start.getTime() - 14 * day), + }); + const member = team.members[i % Math.max(1, team.members.length)]; + if (planned && member) assignees.push({ tenantId, workOrderId: id, userId: member.userId }); + plans.push( + { tenantId, workOrderId: id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }, + { tenantId, workOrderId: id, name: "Pressfitting 15 mm", plannedQuantity: new Prisma.Decimal(6), unit: "Stk", sortOrder: 1 }, + ); + if (i % 10 === 0) { + history.push({ tenantId, workOrderId: id, fromStatus: null, toStatus: "planned", actorId: creator.id }, { tenantId, workOrderId: id, fromStatus: "planned", toStatus: status, actorId: creator.id }); + if (["in_review", "released_for_billing", "billed"].includes(status)) { + reports.push({ tenantId, workOrderId: id, type: "completion", reportDate: start, lineageId: randomUUID(), status: status === "in_review" ? "submitted" : "approved", content: {}, createdById: creator.id }); + } + } + } + await prisma.workOrder.createMany({ data: orders }); + await prisma.workOrderAssignee.createMany({ data: assignees, skipDuplicates: true }); + await prisma.materialPlan.createMany({ data: plans }); + if (history.length) await prisma.workOrderStatusChange.createMany({ data: history }); + if (reports.length) await prisma.report.createMany({ data: reports }); + process.stdout.write(` ${offset + n - existing}/${missing}\r`); + } + await prisma.$executeRawUnsafe("ANALYZE work_orders, work_order_assignees, material_plans, reports, customers, sites"); + const total = await prisma.workOrder.count({ where: { tenantId } }); + console.log(`\n✔ ${missing} Last-Aufträge in ${((Date.now() - t0) / 1000).toFixed(1)} s angelegt — Mandant demo hat jetzt ${total} Aufträge.`); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 86d3c33..2ef0bca 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -3,8 +3,15 @@ * builds the session user via finalizeIdentityLogin (same code path as a real login after * password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET. * - * Usage (from repo root, dev server running): - * BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts + * L10a: covers all core pages per role (admin, backoffice, team lead, technician Nord/Süd, second + * tenant) on the demo data of prisma/seed.ts; ids are resolved from the DB at runtime. Each check + * verifies status, expected text and the absence of error pages/foreign data. PERF=1 measures the + * server render time (second, warm request) and flags pages above PERF_BUDGET_MS (default 2000). + * + * Usage (from repo root, dev server running, demo seed loaded): + * BASE=http://localhost:3110 npx tsx scripts/smoke-auth.ts + * BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts # with timings (after scripts/seed-load.ts) + * ONLY=monteur npx tsx scripts/smoke-auth.ts # filter by e-mail substring */ import "dotenv/config"; import { encode } from "next-auth/jwt"; @@ -13,40 +20,163 @@ import { finalizeIdentityLogin } from "../src/server/auth"; const BASE = process.env.BASE ?? "http://localhost:3099"; const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token"; +const PERF = process.env.PERF === "1"; +const BUDGET = Number(process.env.PERF_BUDGET_MS ?? 2000); -type Check = { path: string; expect?: number[]; mustContain?: string }; +type Check = { path: string; expect?: number[]; mustContain?: string | string[]; mustNotContain?: string[]; redirectTo?: string; perf?: boolean }; +type Plan = { email: string; tenant: string; checks: Check[] }; -const PLAN: Record = { - "backoffice@demo.example": [ - { path: "/dashboard", expect: [200] }, - { path: "/work-orders", expect: [200] }, - { path: "/imports", expect: [200] }, - { path: "/customers", expect: [200] }, - { path: "/sites", expect: [200] }, - { path: "/teams", expect: [200] }, - { path: "/reports", expect: [200] }, - { path: "/documents", expect: [200] }, - { path: "/notifications", expect: [200] }, - { path: "/search?q=a", expect: [200] }, - { path: "/work-orders/emergency-review", expect: [200] }, - { path: "/settings/order-types", expect: [200] }, - { path: "/settings/audit", expect: [200, 403, 307] }, - ], - "monteur@demo.example": [ - { path: "/", expect: [307, 308] }, - { path: "/m", expect: [200] }, - { path: "/m/orders", expect: [200] }, - { path: "/m/emergency", expect: [200] }, - { path: "/m/sync", expect: [200] }, - { path: "/m/profile", expect: [200] }, - ], -}; +async function ids() { + const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } }); + const order = async (ext: string) => prisma.workOrder.findFirst({ where: { tenantId: demo.id, externalOrderNumber: ext }, select: { id: true, number: true } }); + const [d07, d08, d14, d15, d16, d17, d18] = await Promise.all(["DEMO-07", "DEMO-08", "DEMO-14", "DEMO-15", "DEMO-16", "DEMO-17", "DEMO-18"].map(order)); + if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen."); + const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } }); + const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } }); + const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } }); + const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } }); + const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } }); + const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null; + const imp = await prisma.importJob.findFirst({ where: { tenantId: demo.id, status: "review_required" }, orderBy: { createdAt: "desc" } }); + const photo = await prisma.photo.findFirst({ where: { workOrderId: d07.id }, select: { documentId: true } }); + return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo }; +} -async function cookieFor(email: string): Promise { +function plans(x: Awaited>): Plan[] { + const wo = (o: { id: string } | null, suffix = "") => (o ? `/work-orders/${o.id}${suffix}` : "/work-orders"); + const m = (o: { id: string } | null, suffix = "") => (o ? `/m/orders/${o.id}${suffix}` : "/m/orders"); + const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"]; + return [ + { + email: "admin@demo.example", + tenant: "demo", + checks: [ + { path: "/dashboard", mustContain: ["Offene Aufträge", "Überfällig"] }, + { path: "/settings" }, + { path: "/settings/users", mustContain: "backoffice@demo.example" }, + { path: "/settings/audit" }, + { path: "/settings/email" }, + { path: "/settings/lotse" }, + { path: "/settings/lotse/protocol" }, + { path: "/settings/order-types", mustContain: "Montage" }, + { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, + { path: "/settings/numbering" }, + { path: "/account" }, + { path: "/teams", mustContain: ["Team Nord", "Team Süd"] }, + ], + }, + { + email: "backoffice@demo.example", + tenant: "demo", + checks: [ + { path: "/", expect: [307, 308], redirectTo: "/dashboard" }, + { path: "/dashboard", mustContain: "Berichte zur Prüfung", perf: true }, + { path: "/work-orders", perf: true }, + { path: "/work-orders?q=DEMO-07", mustContain: x.d07.number }, + { path: "/work-orders?view=table", perf: true }, + { path: "/work-orders?preset=overdue", perf: true }, + { path: "/work-orders?group=in_review", perf: true }, + { path: "/work-orders?q=Elbblick", perf: true }, + { path: "/work-orders?page=3", perf: true }, + ...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })), + { path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number }, + { path: "/work-orders/conflicts" }, + { path: "/work-orders/emergency-review", mustContain: x.emergency?.number }, + ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []), + { path: "/imports" }, + ...(x.imp ? [{ path: `/imports/${x.imp.id}`, mustContain: "Hausverwaltung Elbblick" }] : []), + { path: "/customers", mustContain: "Hausverwaltung Elbblick" }, + { path: "/customers?status=provisional" }, + { path: `/customers/${x.customer.id}`, mustContain: "Hausverwaltung Elbblick" }, + { path: "/sites", mustContain: "Elbblick" }, + { path: `/sites/${x.site.id}`, mustContain: "Haus A" }, + { path: `/sites/${x.site.id}?tab=history`, mustContain: x.d18.number }, + { path: "/reports", perf: true }, + { path: `/reports/${x.approved.id}`, mustContain: x.d18.number }, + ...(x.inReview ? [{ path: `/reports/${x.inReview.id}` }] : []), + { path: "/documents" }, + { path: "/notifications" }, + { path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true }, + { path: "/teams" }, + { path: "/settings/users", expect: [200, 307, 403] }, + ], + }, + { + email: "teamleiter@demo.example", + tenant: "demo", + checks: [ + { path: "/", expect: [307, 308], redirectTo: "/m" }, + { path: "/m", perf: true }, + { path: "/m/orders", perf: true }, + { path: m(x.d14), mustContain: x.d14?.number }, + { path: m(x.d16), mustContain: x.d16?.number }, + { path: "/reports" }, + ...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []), + { path: m(x.d08), expect: [404] }, + ], + }, + { + email: "monteur@demo.example", + tenant: "demo", + checks: [ + { path: "/", expect: [307, 308], redirectTo: "/m" }, + { path: "/m", mustContain: x.d07.number, perf: true }, + { path: "/m/orders", perf: true }, + { path: "/m/orders?tab=running", mustContain: x.d07.number }, + { path: m(x.d07), mustContain: [x.d07.number, "Schlüssel"] }, + { path: m(x.d07, "/photos") }, + { path: m(x.d07, "/materials"), mustContain: "Kupferrohr" }, + { path: m(x.d07, "/notes") }, + { path: m(x.d07, "/checklist") }, + { path: m(x.d07, "/time") }, + { path: m(x.d07, "/report?type=daily") }, + { path: m(x.d17, "/report?type=completion") }, + { path: m(x.d17, "/sign") }, + ...(x.emergency ? [{ path: m(x.emergency), mustContain: x.emergency.number }] : []), + { path: "/m/emergency" }, + { path: "/m/sync" }, + { path: "/m/offline" }, + { path: "/m/profile", mustContain: "Max Monteur" }, + ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []), + { path: m(x.d08), expect: [404] }, + { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, + { path: "/dashboard", expect: [307, 308], redirectTo: "/m" }, + ], + }, + { + email: "monteur3@demo.example", + tenant: "demo", + checks: [ + { path: "/m", mustContain: x.d08?.number }, + { path: m(x.d15, "/sign"), mustContain: x.d15?.number }, + { path: m(x.d07), expect: [404] }, + ], + }, + { + email: "admin2@demo.example", + tenant: "demo2", + checks: [ + { path: "/dashboard", mustContain: "Offene Aufträge", mustNotContain: ["Elbblick"] }, + { path: "/work-orders", mustContain: "DGUV", mustNotContain: [x.d07.number, "Elbblick"] }, + { path: "/customers", mustNotContain: ["Elbblick"] }, + { path: "/search?q=Elbblick", mustNotContain: ["Hausverwaltung Elbblick"] }, + { path: wo(x.d07), expect: [404] }, + { path: `/customers/${x.customer.id}`, expect: [404] }, + { path: `/sites/${x.site.id}`, expect: [404] }, + { path: `/reports/${x.approved.id}`, expect: [404] }, + ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), + ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), + ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), + ], + }, + ]; +} + +async function cookieFor(email: string, tenant: string): Promise { const identity = await prisma.identity.findUnique({ where: { email } }); if (!identity) throw new Error(`identity ${email} not found (seed?)`); - const user = await finalizeIdentityLogin(identity.id, "demo"); - if (!user) throw new Error(`no active membership for ${email}`); + const user = await finalizeIdentityLogin(identity.id, tenant); + if (!user) throw new Error(`no active membership for ${email} in ${tenant}`); const token = { sub: user.id, name: user.name, @@ -66,26 +196,52 @@ async function cookieFor(email: string): Promise { return `${COOKIE}=${value}`; } +async function fetchTimed(path: string, cookie: string) { + const t0 = performance.now(); + const res = await fetch(BASE + path, { headers: { cookie }, redirect: "manual", signal: AbortSignal.timeout(120_000) }); + const body = res.status === 200 || res.status === 404 ? await res.text() : ""; + return { res, body, ms: Math.round(performance.now() - t0) }; +} + async function main() { + const only = process.env.ONLY; let failures = 0; - for (const [email, checks] of Object.entries(PLAN)) { - const cookie = await cookieFor(email); - console.log(`\n== ${email}`); - for (const c of checks) { - const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" }); - const body = res.status === 200 ? await res.text() : ""; - const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body); - const okStatus = !c.expect || c.expect.includes(res.status); - const okText = !c.mustContain || body.includes(c.mustContain); - const ok = okStatus && okText && !errorPage; - if (!ok) failures++; + const slow: string[] = []; + // PERF=1 runs only the perf-marked pages and ignores their content checks: with scripts/seed-load.ts data the + // demo orders are no longer guaranteed on the first page / in the capped mobile lists. + const all = plans(await ids()) + .filter((p) => !only || p.email.includes(only)) + .map((p) => (PERF ? { ...p, checks: p.checks.filter((c) => c.perf).map((c) => ({ ...c, mustContain: undefined })) } : p)) + .filter((p) => p.checks.length > 0); + let total = 0; + for (const plan of all) { + const cookie = await cookieFor(plan.email, plan.tenant); + console.log(`\n== ${plan.email} (${plan.tenant})`); + for (const c of plan.checks) { + total++; + let { res, body, ms } = await fetchTimed(c.path, cookie); + if (PERF && c.perf) ({ res, body, ms } = await fetchTimed(c.path, cookie)); // warm measurement + const expect = c.expect ?? [200]; + const errorPage = /Application error|Internal Server Error|Unhandled Runtime Error|NEXT_REDIRECT|digest:/i.test(res.status === 200 ? body : ""); + const okStatus = expect.includes(res.status); const loc = res.headers.get("location"); - console.log(`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${errorPage ? " [error page]" : ""}`); + const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false); + const must = (Array.isArray(c.mustContain) ? c.mustContain : c.mustContain ? [c.mustContain] : []).filter(Boolean) as string[]; + const missing = res.status === 200 ? must.filter((s) => !body.includes(s)) : []; + const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s)); + const ok = okStatus && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0; + if (!ok) failures++; + const perfNote = PERF && c.perf ? ` ${ms} ms${ms > BUDGET ? " ⚠ über Budget" : ""}` : ` (${ms} ms)`; + if (PERF && c.perf && ms > BUDGET) slow.push(`${plan.email} ${c.path} ${ms} ms`); + console.log( + `${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${perfNote}${errorPage ? " [error page]" : ""}${missing.length ? ` [fehlt: ${missing.join(", ")}]` : ""}${leaked.length ? ` [fremde Daten: ${leaked.join(", ")}]` : ""}`, + ); } } await prisma.$disconnect(); - console.log(failures ? `\n${failures} Fehler` : "\nOK"); - process.exit(failures ? 1 : 0); + if (PERF) console.log(slow.length ? `\n⚠ ${slow.length} Seite(n) über ${BUDGET} ms:\n${slow.join("\n")}` : `\nPerformance: alle gemessenen Seiten ≤ ${BUDGET} ms`); + console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`); + process.exit(failures || (PERF && slow.length) ? 1 : 0); } main().catch((err) => {