L10a Qualität & Abnahmetests: Authentifizierter Durchstich aller Kernseiten je Rolle, Last-Seed (5 000 Aufträge) mit Performance-Messung, Demo-Import für die Prüfmaske
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+199
-43
@@ -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<string, Check[]> = {
|
||||
"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] },
|
||||
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 };
|
||||
}
|
||||
|
||||
function plans(x: Awaited<ReturnType<typeof ids>>): 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"] },
|
||||
],
|
||||
"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] },
|
||||
},
|
||||
{
|
||||
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): Promise<string> {
|
||||
async function cookieFor(email: string, tenant: string): Promise<string> {
|
||||
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<string> {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user