From 45078623757371aa55da544e38f68270c6f2d8a5 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:16:11 +0200 Subject: [PATCH] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20E2E-?= =?UTF-8?q?Prozesstests=20(regul=C3=A4rer=20Auftrag,=20mehrt=C3=A4gig,=20N?= =?UTF-8?q?otdienst,=20Offline-Sync,=20Pflichtfotos/Unterschrift,=20Dublet?= =?UTF-8?q?ten,=20Mandantentrennung=20systematisch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/lib/e2e-fixture.ts | 229 ++++++++++++++++ scripts/test-e2e-emergency.ts | 122 +++++++++ scripts/test-e2e-guards.ts | 151 ++++++++++ scripts/test-e2e-import-duplicates.ts | 120 ++++++++ scripts/test-e2e-multiday-order.ts | 145 ++++++++++ scripts/test-e2e-offline-sync.ts | 153 +++++++++++ scripts/test-e2e-regular-order.ts | 240 ++++++++++++++++ scripts/test-e2e-tenant-isolation.ts | 379 ++++++++++++++++++++++++++ 8 files changed, 1539 insertions(+) create mode 100644 scripts/lib/e2e-fixture.ts create mode 100644 scripts/test-e2e-emergency.ts create mode 100644 scripts/test-e2e-guards.ts create mode 100644 scripts/test-e2e-import-duplicates.ts create mode 100644 scripts/test-e2e-multiday-order.ts create mode 100644 scripts/test-e2e-offline-sync.ts create mode 100644 scripts/test-e2e-regular-order.ts create mode 100644 scripts/test-e2e-tenant-isolation.ts diff --git a/scripts/lib/e2e-fixture.ts b/scripts/lib/e2e-fixture.ts new file mode 100644 index 0000000..ee3f108 --- /dev/null +++ b/scripts/lib/e2e-fixture.ts @@ -0,0 +1,229 @@ +// L10a shared fixture for the acceptance tests (scripts/test-e2e-*.ts, scripts/test-security-*.ts). +// Not a test itself (the runner only picks up scripts/test-*.ts at top level). +// +// createTenant(slug) builds a complete zz test tenant exactly like a provisioned one: tenant +// settings, the four standard roles WITH their permissions in the DB (needed by recipient +// resolution, conflict re-apply and requireApiContext), users per role, Team Nord (lead + 2 +// technicians), Team Süd (lead2 + outsider), a customer with contact and a site. +// cleanupTenants() removes every row of all tenant models (FK-safe order) and the test identities. + +import "dotenv/config"; +import sharp from "sharp"; +import { ZodError } from "zod"; +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../../src/server/services/context"; + +export const E2E_DOMAIN = "@zz-qualitaet.test"; + +let failureCount = 0; +export const failures = () => failureCount; + +export function ok(cond: boolean, msg: string): boolean { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failureCount++; + return cond; +} + +export function section(title: string) { + console.log(`\n— ${title} —`); +} + +/** Error code of a promise: "ok", a ServiceError code, or "error:". */ +export async function codeOf(p: Promise | (() => Promise)): Promise { + try { + await (typeof p === "function" ? p() : p); + return "ok"; + } catch (e) { + if (e instanceof ServiceError) return e.code; + // Zod validation errors surface as `invalid` in every adapter (toErrorResponse, action state) + if (e instanceof ZodError) return "invalid"; + const code = (e as { code?: unknown }).code; + if (typeof code === "string" && ["not_found", "forbidden", "invalid", "conflict", "blocked"].includes(code)) return code; + return `error:${(e as Error).message?.split("\n")[0]}`; + } +} + +export async function expectCode(fn: () => Promise, codes: string | string[], msg: string): Promise { + const got = await codeOf(fn); + const wanted = Array.isArray(codes) ? codes : [codes]; + ok(wanted.includes(got), `${msg}${wanted.includes(got) ? "" : ` — erhalten ${got}, erwartet ${wanted.join("|")}`}`); + return got; +} + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +const PERSONAS = { + admin: "tenant-admin", + backoffice: "backoffice", + lead: "team-lead", + lead2: "team-lead", + tech: "technician", + tech2: "technician", + outsider: "technician", +} as const satisfies Record; +export type Persona = keyof typeof PERSONAS; + +export type TenantFixture = { + slug: string; + tenantId: string; + users: Record; + ctx: Record; + teamId: string; + team2Id: string; + customerId: string; + contactId: string; + siteId: string; +}; + +export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise { + const tenant = await prisma.tenant.create({ data: { name: opts.orgName ?? `ZZ Qualität ${slug}`, slug } }); + await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: opts.orgName ?? `ZZ Qualität ${slug} GmbH`, timezone: "Europe/Berlin", address: "Prüfweg 1, 20095 Hamburg" } }); + + const roleIds = {} as Record; + for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) { + const perms = await Promise.all(ROLE_DEFS[key].permissions.map((p) => prisma.permission.upsert({ where: { key: p }, update: {}, create: { key: p } }))); + const role = await prisma.role.create({ + data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: perms.map((p) => ({ permissionId: p.id })) } }, + }); + roleIds[key] = role.id; + } + + const users = {} as TenantFixture["users"]; + const ctx = {} as TenantFixture["ctx"]; + for (const persona of Object.keys(PERSONAS) as Persona[]) { + const email = `${persona}-${slug}${E2E_DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + const user = await prisma.user.create({ + data: { tenantId: tenant.id, identityId: identity.id, email, name: `${persona} ${slug}`, userRoles: { create: [{ roleId: roleIds[PERSONAS[persona]] }] } }, + }); + users[persona] = { id: user.id, email, identityId: identity.id }; + ctx[persona] = ctxFor(tenant.id, user.id, PERSONAS[persona]); + } + + const since = new Date(Date.now() - 30 * 86400_000); + const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } }); + await prisma.teamMember.createMany({ + data: [users.tech.id, users.tech2.id].map((userId) => ({ tenantId: tenant.id, teamId: team.id, userId, validFrom: since })), + }); + const team2 = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd", leaderUserId: users.lead2.id } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team2.id, userId: users.outsider.id, validFrom: since } }); + + const customer = await prisma.customer.create({ + data: { tenantId: tenant.id, customerNumber: `K-${slug}`, companyName: `Hausverwaltung ${slug} GmbH`, street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "040 555 1000", email: `kontakt@${slug}.example` }, + }); + const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: "Frau Prüf", role: "Objektbetreuung", phone: "040 555 1001" } }); + const site = await prisma.site.create({ + data: { tenantId: tenant.id, customerId: customer.id, contactId: contact.id, name: `Wohnanlage ${slug}`, street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" }, + }); + + return { slug, tenantId: tenant.id, users, ctx, teamId: team.id, team2Id: team2.id, customerId: customer.id, contactId: contact.id, siteId: site.id }; +} + +/** Deletes all rows of the given tenants (all TENANT_MODELS, FK-safe) plus their test identities. */ +export async function cleanupTenants(slugs: string[]): Promise { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.syncOperation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.notificationPreference.deleteMany(w); + await prisma.mailLog.deleteMany(w); + await prisma.authToken.deleteMany(w); + await prisma.aiGeneration.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.updateMany({ ...w, data: { workOrderId: null, siteId: null, customerId: null } }); + await prisma.workOrder.deleteMany(w); + await prisma.importJob.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.teamMember.deleteMany(w); + await prisma.team.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.checklistTemplate.deleteMany(w); + await prisma.orderType.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.tenantModule.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } }); + await prisma.user.deleteMany(w); + await prisma.rolePermission.deleteMany({ where: { role: { tenantId: { in: ids } } } }); + await prisma.role.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: E2E_DOMAIN }, memberships: { none: {} } } }); +} + +/** Runs `body` between setup/cleanup, prints the summary and exits with the right code. */ +export async function runSuite(name: string, slugs: string[], body: () => Promise): Promise { + let crashed = false; + try { + await cleanupTenants(slugs); + await body(); + } catch (err) { + crashed = true; + console.error(`\n✗ FEHLER ${name} abgebrochen:`, err); + } finally { + await cleanupTenants(slugs).catch((e) => console.error("cleanup failed", e)); + try { + const { closeJobQueues } = await import("../../src/server/jobs/queues"); + await closeJobQueues(); + } catch { + /* queues optional */ + } + try { + const { closeQueues } = await import("../../src/server/mail/queue"); + await closeQueues(); + } catch { + /* mail queue optional */ + } + await prisma.$disconnect(); + } + const n = failures() + (crashed ? 1 : 0); + console.log(n === 0 ? `\nOK — ${name}: alle Nachweise erfüllt.` : `\n${n} FEHLER in ${name}.`); + process.exit(n === 0 ? 0 : 1); +} + +// ---------- binary fixtures ---------- + +export async function jpegBytes(label = "Foto"): Promise { + const svg = `${label}`; + return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer(); +} + +export async function pngBytes(): Promise { + return sharp({ create: { width: 300, height: 100, channels: 3, background: { r: 255, g: 255, b: 255 } } }).png().toBuffer(); +} + +/** Local calendar day key (Europe/Berlin) with an offset in days. */ +export function dayKey(offsetDays = 0): string { + const d = new Date(Date.now() + offsetDays * 86400_000); + return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" }).format(d); +} + +/** Instant at a given Berlin wall-clock hour of the day `offsetDays` from today. */ +export function berlinAt(offsetDays: number, hh: number, mm = 0): Date { + const key = dayKey(offsetDays); + // Europe/Berlin is UTC+1 or UTC+2; resolve the offset of that day at noon. + const probe = new Date(`${key}T12:00:00Z`); + const berlinNoon = Number(new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Berlin", hour: "2-digit", hour12: false }).format(probe)); + const offsetHours = berlinNoon - 12; + return new Date(`${key}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00${offsetHours >= 0 ? "+" : "-"}${String(Math.abs(offsetHours)).padStart(2, "0")}:00`); +} diff --git a/scripts/test-e2e-emergency.ts b/scripts/test-e2e-emergency.ts new file mode 100644 index 0000000..538237e --- /dev/null +++ b/scripts/test-e2e-emergency.ts @@ -0,0 +1,122 @@ +// L10a E2E §43.3 / §39 „Notdiensteinsatz" — Monteur legt Notdienst mit vorläufigem Kunden an → Einsatz → +// Abschluss ohne Unterschrift (Kunde abwesend) → Backoffice wird informiert → Backoffice ordnet den +// bestehenden Kunden zu, bestätigt das Objekt, ergänzt den Auftrag → Berichtsfreigabe → Abrechnung. +// Dazu Rollen (Monteur/Teamleiter dürfen nicht prüfen) und Mandantentrennung. +// +// Lauf: npx tsx scripts/test-e2e-emergency.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { createEmergencyOrder } from "../src/server/services/emergency/create"; +import { + assignEmergencyToCustomer, + completeEmergencyOrderData, + correctEmergencySite, + getEmergencyReview, + listEmergencyReviews, + releaseEmergencyForBilling, +} from "../src/server/services/emergency/review"; +import { markBilled } from "../src/server/services/work-orders/release-billing"; +import { endSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { getCustomer } from "../src/server/services/customers/customers"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-nd-a"; +const SLUG_B = "zz-q-e2e-nd-b"; +const noPdf = { dispatchPdf: async () => undefined }; + +runSuite("E2E Notdienst", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + section("1. Monteur legt Notdienst mit vorläufigem Kunden an"); + const startedAt = new Date(Date.now() - 2 * 3600_000); + const r = await createEmergencyOrder(tech, { + clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, + // same phone + postal code as the existing customer → duplicate candidate for the backoffice + customer: { mode: "new", lastName: "Hausverwaltung Hafen", phone: "040 5551000", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, + site: { mode: "new", name: "Keller", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, + onSiteContact: { name: "Hausmeister Hafen", phone: "0171 5550000" }, + reason: "Heizung ausgefallen, Wohnungen ohne Warmwasser", + startedAt: startedAt.toISOString(), + }); + const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r.workOrderId }, include: { customer: true, site: true } }); + ok(/^N-\d+$/.test(wo.number) && wo.isEmergency && wo.status === "in_progress", `Notdienstauftrag ${wo.number} in Arbeit`); + ok(wo.customer.status === "provisional" && wo.customer.customerNumber === null && wo.site?.status === "provisional", "Kunde und Objekt vorläufig (ohne Kundennummer)"); + ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "running" } })) === 1, "Einsatz läuft (Arbeitszeit gestartet)"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.created", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst angelegt“"); + await expectCode(() => getCustomer(A.ctx.outsider, wo.customerId), "not_found", "anderer Monteur sieht den vorläufigen Kunden nicht"); + await expectCode(() => getCustomer(B.ctx.admin, wo.customerId), "not_found", "Mandant B sieht den vorläufigen Kunden nicht"); + + section("2. Dokumentation und Abschluss ohne Unterschrift"); + await createNote(tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "Umwälzpumpe getauscht, Anlage wieder in Betrieb." })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Umwälzpumpe 25-40", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Pumpe defekt" })); + const up = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: await jpegBytes("Pumpe"), name: "pumpe.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "after" })); + await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id })); + const report = (await createCompletionReport(tech, { workOrderId: wo.id })).report; + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Umwälzpumpe getauscht, Warmwasser wieder verfügbar." } }); + await expectCode(() => captureSignature(tech, { reportId: report.id, outcome: "customer_absent", confirmationText: "x" }), "invalid", "Kunde abwesend ohne Begründung → invalid"); + await captureSignature(tech, { reportId: report.id, outcome: "customer_absent", reason: "Nachts niemand erreichbar", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: report.id }); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Abschlussbericht eingereicht → Zur Prüfung"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.completed", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst abgeschlossen“ (In-App)"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "work_order.signature_missing", entityId: wo.id } })) === 1, "Backoffice erhält Hinweis „Unterschrift fehlt“"); + const mails = await prisma.mailLog.count({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }); + ok(mails >= 1, `Pflicht-E-Mail an das Backoffice eingereiht/protokolliert (${mails})`); + + section("3. Rollen und Mandantentrennung der Nachbearbeitung"); + await expectCode(() => listEmergencyReviews(tech), "forbidden", "Monteur: Prüfliste → forbidden"); + await expectCode(() => getEmergencyReview(A.ctx.lead, wo.id), "forbidden", "Teamleiter: Prüfung → forbidden"); + await expectCode(() => getEmergencyReview(B.ctx.backoffice, wo.id), "not_found", "Mandant B: Prüfung → not_found"); + await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: wo.id, targetCustomerId: A.customerId }), "not_found", "Mandant B: Kunde zuordnen → not_found"); + ok(!(await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).some((x) => x.id === wo.id), "Mandant B: Notdienst nicht in der Liste"); + + section("4. Backoffice prüft am nächsten Arbeitstag die Stammdaten"); + const listed = (await listEmergencyReviews(bo)).find((x) => x.id === wo.id); + ok(!!listed && listed.progress.count === 0 && listed.progress.total === 5, "in „Zu prüfen“ mit 0 von 5 Prüfschritten"); + const review = await getEmergencyReview(bo, wo.id); + ok(review.duplicates.some((d) => d.customerId === A.customerId), "bestehender Kunde als Dublettenkandidat vorgeschlagen"); + await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung mit vorläufigen Stammdaten → blocked"); + const assigned = await assignEmergencyToCustomer(bo, { workOrderId: wo.id, targetCustomerId: A.customerId }); + const provisional = await prisma.customer.findUniqueOrThrow({ where: { id: assigned.sourceId } }); + const afterAssign = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id }, include: { site: true } }); + ok(afterAssign.customerId === A.customerId && provisional.status === "merged" && provisional.mergedIntoId === A.customerId, "Auftrag dem bestehenden Kunden zugeordnet, vorläufiger Kunde zusammengeführt"); + ok(afterAssign.site?.customerId === A.customerId && afterAssign.version > wo.version, "Einsatzort mit umgehängt, Auftragsversion erhöht (Offline-Schutz)"); + await correctEmergencySite(bo, { workOrderId: wo.id, patch: { name: "Heizungskeller Hafenstraße 12", accessNotes: "Hausmeister öffnet" }, confirm: true }); + ok((await prisma.site.findUniqueOrThrow({ where: { id: afterAssign.siteId! } })).status === "active", "Objekt korrigiert und bestätigt"); + const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } }); + await completeEmergencyOrderData(bo, { workOrderId: wo.id, title: "Notdienst: Ausfall Heizung", orderTypeId: notdienst.id, billingType: "time_material" }); + await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung ohne freigegebenen Bericht → blocked"); + await approveReport(A.ctx.lead, { reportId: report.id }, noPdf); + await approveReport(bo, { reportId: report.id }, noPdf); + ok((await getEmergencyReview(bo, wo.id)).progress.count === 4, "4 von 5 Prüfschritten erledigt"); + + section("5. Auftrag wird zur Abrechnung freigegeben"); + const released = await releaseEmergencyForBilling(bo, { workOrderId: wo.id }); + ok(released.status === "released_for_billing", "Notdienst zur Abrechnung freigegeben"); + ok((await getEmergencyReview(bo, wo.id)).progress.count === 5, "5 von 5 Prüfschritten erledigt"); + ok(!(await listEmergencyReviews(bo)).some((x) => x.id === wo.id) && (await listEmergencyReviews(bo, { filter: "all" })).some((x) => x.id === wo.id), "aus „Zu prüfen“ entfernt, unter „Alle“ sichtbar"); + ok((await codeOf(markBilled(tech, { workOrderId: wo.id }))) === "forbidden", "Monteur darf nicht abrechnen"); + ok((await markBilled(bo, { workOrderId: wo.id })).status === "billed", "abgerechnet"); + const audits = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: wo.id } }); + ok(audits >= 4, `Prüfschritte auditiert (${audits})`); + + const hist = await getSiteHistory(bo, afterAssign.siteId!); + const entry = hist.items.find((i) => i.workOrderId === wo.id); + ok(!!entry && entry.isEmergency && entry.approvedReports.length === 1 && !entry.signed, "Objekt-Historie: Notdienst mit freigegebenem Bericht, ohne Unterschrift"); +}); diff --git a/scripts/test-e2e-guards.ts b/scripts/test-e2e-guards.ts new file mode 100644 index 0000000..482cd67 --- /dev/null +++ b/scripts/test-e2e-guards.ts @@ -0,0 +1,151 @@ +// L10a E2E §43.3 „Fehlende Pflichtfotos" und „Fehlende Unterschrift". +// +// Pflichtfotos: Pflichtfoto + Checklistenpunkt „mit Foto" blockieren technisch abschließen, +// Abschlussbericht und Sync-Op mit strukturierter Blocker-Liste; Blocker verschwinden erst mit den +// passenden Fotos. Unterschrift: ohne Unterschrift → „Unterschrift ausstehend“ (kein Weg zur Prüfung +// oder Abrechnung), Ablehnung/Abwesenheit nur mit Grund (→ Prüfung + Hinweis ans Backoffice), +// „später“ + Nachreichen, „nicht erforderlich“ nur ohne Pflicht, erfasste Unterschrift unveränderlich. +// +// Lauf: npx tsx scripts/test-e2e-guards.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { CompletionBlocker } from "../src/lib/work-orders/status"; +import { ServiceError } from "../src/server/services/context"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { endSession, startSession } from "../src/server/services/field/sessions"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFile } from "../src/server/services/documents/store"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { contentOf } from "../src/server/services/reports/common"; +import { checklistTogglePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-guard-a"; +const SLUG_B = "zz-q-e2e-guard-b"; + +async function blockersOf(fn: () => Promise): Promise { + try { + await fn(); + return "ok"; + } catch (err) { + return err instanceof ServiceError && err.code === "blocked" ? (err.details as CompletionBlocker[]) : String((err as { code?: string }).code ?? (err as Error).message); + } +} + +/** Order assigned to tech, worked and ended; ready for the completion step. */ +async function workedOrder(A: TenantFixture, input: { signatureRequired: boolean; title: string; photoRequirements?: { key: string; label: string }[]; checklistItems?: { label: string; required: boolean; requiresPhoto?: boolean }[] }) { + const wo = await createWorkOrder(A.ctx.backoffice, { customerId: A.customerId, siteId: A.siteId, applyTemplate: false, ...input }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() })); + await endSession(A.ctx.tech, sessionControlPayload.parse({ workOrderId: wo.id })); + return wo.id; +} + +async function readyReport(A: TenantFixture, workOrderId: string) { + const { report } = await createCompletionReport(A.ctx.tech, { workOrderId }); + await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Arbeiten ausgeführt." } }); + return report.id; +} + +const status = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).status; + +runSuite("E2E Pflichtfotos und Unterschrift", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const tech = A.ctx.tech; + const jpeg = await jpegBytes("Nachweis"); + + // ================= Pflichtfotos ================= + section("Fehlende Pflichtfotos blockieren den Abschluss"); + const w1 = await workedOrder(A, { + title: "Wärmepumpe montieren", + signatureRequired: true, + photoRequirements: [{ key: "typenschild", label: "Typenschild" }], + checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }], + }); + const b1 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w1, to: "technically_completed" })); + ok(Array.isArray(b1) && b1.some((b) => b.kind === "photo_requirement" && b.label === "Typenschild") && b1.some((b) => b.kind === "checklist_item"), "technisch abschließen → blocked mit Pflichtfoto + Checklistenpunkt"); + const b2 = await blockersOf(() => createCompletionReport(tech, { workOrderId: w1 })); + ok(Array.isArray(b2) && b2.length === 2, "Abschlussbericht → blocked mit derselben Liste"); + const listed = await getCompletionBlockers(tech, w1); + ok(listed.length === 2, "Blocker-Liste für die UI abrufbar"); + await expectCode(() => getCompletionBlockers(B.ctx.tech, w1), "not_found", "Mandant B: Blocker-Liste → not_found"); + await expectCode(() => getCompletionBlockers(A.ctx.outsider, w1), "not_found", "Monteur ohne Zuweisung: Blocker-Liste → not_found"); + const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w1 }, include: { checklistItems: true, photoRequirements: true } }); + const viaSync = await applyOperations(tech, { deviceId: "g", operations: [{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: wo1.version, payload: { workOrderId: w1, to: "technically_completed" }, clientCreatedAt: new Date().toISOString() }] }); + ok(viaSync.results[0].status === "rejected" && viaSync.results[0].errorCode === "blocked", "auch offline (Sync) → rejected blocked"); + + await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: w1, itemId: wo1.checklistItems[0].id, checked: true })); + const b3 = await getCompletionBlockers(tech, w1); + ok(b3.some((b) => b.kind === "checklist_item"), "abgehakt ohne Foto → Checklistenpunkt „mit Foto“ bleibt offen"); + const notAnImage = Buffer.from("%PDF-1.4 kein Foto"); + await expectCode(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: notAnImage, name: "fake.jpg", type: "image/jpeg" }), "invalid", "Nicht-Bild als Foto → invalid"); + const p1 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "funktion.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p1.documentId, checklistItemId: wo1.checklistItems[0].id, phase: "after" })); + const b4 = await getCompletionBlockers(tech, w1); + ok(b4.length === 1 && b4[0].kind === "photo_requirement", "Foto am Checklistenpunkt → nur noch Pflichtfoto offen"); + const p2 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); + await expectCode(() => attachPhoto(A.ctx.tech2, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id })), ["not_found", "forbidden"], "Foto eines anderen Nutzers kann nicht angehängt werden"); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id, phase: "after" })); + ok((await getCompletionBlockers(tech, w1)).length === 0, "alle Pflichtfotos vorhanden → keine Blocker"); + ok((await codeOf(createCompletionReport(tech, { workOrderId: w1 }))) === "ok", "Abschlussbericht jetzt möglich"); + + // ================= Unterschrift ================= + section("Fehlende Unterschrift: ohne Erfassung bleibt der Auftrag „Unterschrift ausstehend“"); + const w2 = await workedOrder(A, { title: "Ohne Unterschrift", signatureRequired: true }); + const r2 = await readyReport(A, w2); + await submitReport(tech, { reportId: r2 }); + ok((await status(w2)) === "signature_pending", "Abschluss ohne Unterschrift → signature_pending"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w2, userId: A.users.backoffice.id } })) >= 1, "Backoffice erhält „Unterschrift fehlt“"); + const b5 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w2, to: "in_review" })); + ok(Array.isArray(b5) && b5.some((b) => b.kind === "missing_field" && b.field === "signature"), "zur Prüfung ohne Unterschrift → blocked (signature)"); + await expectCode(() => releaseForBilling(A.ctx.backoffice, { workOrderId: w2 }), "invalid", "Abrechnung aus „Unterschrift ausstehend“ nicht möglich"); + const img2 = await storeFile(tech, { bytes: await pngBytes(), fileName: "u.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: w2 } }); + await captureSignature(tech, { reportId: r2, outcome: "signed", signerName: "Herr Nachtrag", imageDocumentId: img2.id, confirmationText: "Bestätigt" }); + ok((await status(w2)) === "in_review", "nachgereichte Unterschrift → automatisch zur Prüfung"); + await expectCode(() => captureSignature(tech, { reportId: r2, outcome: "refused", reason: "doch nicht", confirmationText: "x" }), "conflict", "erfasste Unterschrift kann nicht überschrieben werden"); + + section("Unterschrift verweigert / Kunde abwesend: nur mit Grund"); + const w3 = await workedOrder(A, { title: "Kunde verweigert", signatureRequired: true }); + const r3 = await readyReport(A, w3); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "refused", confirmationText: "x" }), "invalid", "Verweigerung ohne Grund → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "customer_absent", reason: " ", confirmationText: "x" }), "invalid", "Abwesenheit mit leerem Grund → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Ohne Bild", confirmationText: "x" }), "invalid", "Unterschrift ohne Bild → invalid"); + await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Fremdes Bild", imageDocumentId: img2.id, confirmationText: "x" }), "invalid", "Unterschriftsbild eines anderen Auftrags → invalid"); + await captureSignature(tech, { reportId: r3, outcome: "refused", reason: "Kunde mit Ausführung nicht einverstanden", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r3 }); + const rep3 = await prisma.report.findUniqueOrThrow({ where: { id: r3 } }); + ok((await status(w3)) === "in_review", "Verweigerung mit Grund → Zur Prüfung"); + ok(contentOf(rep3).signature?.outcome === "refused" && contentOf(rep3).signature?.reason === "Kunde mit Ausführung nicht einverstanden", "Grund im Berichts-Snapshot"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w3 } })) >= 1, "Backoffice wird über die fehlende Unterschrift informiert"); + await expectCode(() => captureSignature(B.ctx.tech, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); + await expectCode(() => captureSignature(A.ctx.outsider, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Monteur ohne Zuweisung kann keine Unterschrift erfassen"); + + section("„Später“ und „nicht erforderlich“"); + const w4 = await workedOrder(A, { title: "Später unterschreiben", signatureRequired: true }); + const r4 = await readyReport(A, w4); + await expectCode(() => captureSignature(tech, { reportId: r4, outcome: "not_required", confirmationText: "x" }), "forbidden", "Monteur: „nicht erforderlich“ bei Pflicht-Unterschrift → forbidden"); + await captureSignature(tech, { reportId: r4, outcome: "later", reason: "Ansprechpartner erst morgen da", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r4 }); + ok((await status(w4)) === "signature_pending", "„später“ → Unterschrift ausstehend"); + await captureSignature(tech, { reportId: r4, outcome: "customer_absent", reason: "Auch am Folgetag nicht erreichbar", confirmationText: "Bestätigt" }); + ok((await status(w4)) === "in_review", "Nachtrag „Kunde abwesend“ mit Grund → Zur Prüfung"); + + const w5 = await workedOrder(A, { title: "Besichtigung ohne Unterschrift", signatureRequired: false }); + const r5 = await readyReport(A, w5); + await captureSignature(tech, { reportId: r5, outcome: "not_required", confirmationText: "Bestätigt" }); + await submitReport(tech, { reportId: r5 }); + ok((await status(w5)) === "in_review", "Auftrag ohne Unterschriftspflicht: „nicht erforderlich“ → Zur Prüfung"); +}); diff --git a/scripts/test-e2e-import-duplicates.ts b/scripts/test-e2e-import-duplicates.ts new file mode 100644 index 0000000..c61aa53 --- /dev/null +++ b/scripts/test-e2e-import-duplicates.ts @@ -0,0 +1,120 @@ +// L10a E2E §43.3 „Dublettenprüfung" beim Import (Spec §7.3, US-003) — Kandidaten werden gefunden +// (Kundennummer, Firmenname, Adresse Straße/Str., E-Mail unabhängig von Groß-/Kleinschreibung, +// Telefon unabhängig vom Format), aber NIE automatisch zugeordnet oder zusammengeführt; die +// Entscheidung (bestehend / neu) trifft das Backoffice und wird protokolliert. Manuelle Anlage +// zeigt dieselben Kandidaten. Mandant B findet nichts von A, Monteure haben keinen Zugriff. +// +// Lauf: npx tsx scripts/test-e2e-import-duplicates.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +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 { confirmImport } from "../src/server/services/imports/confirm"; +import { getImportDetail } from "../src/server/services/imports/queries"; +import { extractionToForm } from "../src/lib/imports/review"; +import { createCustomer } from "../src/server/services/customers/customers"; +import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import type { ServiceCtx } from "../src/server/services/context"; +import { buildPdf } from "./make-sample-pdfs"; +import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-dup-a"; +const SLUG_B = "zz-q-e2e-dup-b"; + +type Candidate = { customerId: string; score: number; reasons: string[] }; + +async function importWith(ctx: ServiceCtx, extraction: Partial) { + const pdf = buildPdf([[{ text: "Auftrag" }]]); + const job = await createImport(ctx, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined }); + const processed = await processImport(ctx, job.id, { provider: new FakeExtractionProvider({ extraction }), loadBytes: async () => pdf }); + return { job, candidates: (processed.duplicateCandidates ?? []) as Candidate[] }; +} + +runSuite("E2E Dublettenprüfung beim Import", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + + const firm = await prisma.customer.create({ + data: { tenantId: A.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "+49 40 5551234" }, + }); + const person = await prisma.customer.create({ + data: { tenantId: A.tenantId, customerNumber: "K-50002", firstName: "Erika", lastName: "Beispiel", email: "erika@beispiel.example", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg" }, + }); + const foreign = await prisma.customer.create({ data: { tenantId: B.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", postalCode: "20457", phone: "040 5551234" } }); + const firmBefore = await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } }); + + section("1. Firmenkunde: Nummer, Name, Adresse (Str.), Telefon in anderem Format"); + const i1 = await importWith(bo, { + customerNumber: { value: "K-50001", confidence: 0.9 }, + companyName: { value: "Musterbau Haustechnik GmbH", confidence: 0.95 }, + customerAddress: { value: { street: "Hafenstr.", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + phone: { value: "040 / 555 12 34", confidence: 0.9 }, + title: { value: "Wartung Lüftung", confidence: 0.9 }, + }); + const top = i1.candidates[0]; + ok(top?.customerId === firm.id, "bestehender Firmenkunde ist bester Kandidat"); + ok(!!top && ["customer_number", "company_name", "address", "phone"].every((r) => top.reasons.includes(r)), `Gründe: ${top?.reasons.join(", ")}`); + ok(!i1.candidates.some((c) => c.customerId === foreign.id), "Kandidaten nie aus Mandant B"); + ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "keine automatische Zuordnung, kein Auftrag ohne Bestätigung"); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } })).updatedAt.getTime() === firmBefore.updatedAt.getTime(), "bestehender Kunde unverändert"); + + section("2. Privatkunde: E-Mail in anderer Schreibweise"); + const i2 = await importWith(bo, { + customerFirstName: { value: "Erika", confidence: 0.9 }, + customerLastName: { value: "Beispiel", confidence: 0.9 }, + email: { value: "ERIKA@Beispiel.example", confidence: 0.9 }, + title: { value: "Reparatur Mischbatterie", confidence: 0.9 }, + }); + ok(i2.candidates.some((c) => c.customerId === person.id && c.reasons.includes("email")), "Privatkunde über E-Mail gefunden"); + + section("3. Neukunde ohne Überschneidung"); + const i3 = await importWith(bo, { + companyName: { value: "Kieler Förde Wohnbau AG", confidence: 0.9 }, + customerAddress: { value: { street: "Holtenauer Straße", houseNumber: "200", postalCode: "24105", city: "Kiel" }, confidence: 0.9 }, + title: { value: "Neubau Heizzentrale", confidence: 0.9 }, + }); + ok(i3.candidates.length === 0, "kein Kandidat bei Neukunde"); + + section("4. Entscheidung des Backoffice"); + const countBefore = await prisma.customer.count({ where: { tenantId: A.tenantId } }); + const d2 = await getImportDetail(bo, i2.job.id); + const c2 = await confirmImport(bo, i2.job.id, extractionToForm(d2.extraction.fields, { customerId: person.id })); + ok(c2.customerId === person.id && (await prisma.customer.count({ where: { tenantId: A.tenantId } })) === countBefore, "Kandidat übernommen → kein neuer Kunde"); + const d1 = await getImportDetail(bo, i1.job.id); + const f1 = extractionToForm(d1.extraction.fields); + ok(f1.customerMode === "new", "Formular schlägt ohne Entscheidung keinen bestehenden Kunden automatisch vor"); + await expectCode(() => confirmImport(bo, i1.job.id, f1), "conflict", "„neu anlegen“ mit vergebener Kundennummer → conflict, keine Teilanlage"); + f1.customer!.customerNumber = ""; + const c1 = await confirmImport(bo, i1.job.id, f1); + const newFirm = await prisma.customer.findUniqueOrThrow({ where: { id: c1.customerId } }); + ok(newFirm.id !== firm.id && /^K-\d+$/.test(newFirm.customerNumber ?? ""), `bewusst neu angelegt mit eigener Nummer (${newFirm.customerNumber})`); + ok((await prisma.customer.count({ where: { tenantId: A.tenantId, status: "merged" } })) === 0, "keine automatische Zusammenführung"); + const job1 = await prisma.importJob.findUniqueOrThrow({ where: { id: i1.job.id } }); + ok((job1.corrections as { decisions?: { customerMode?: string } }).decisions?.customerMode === "new", "Entscheidung „neu“ am Import protokolliert"); + const d3 = await getImportDetail(bo, i3.job.id); + const c3 = await confirmImport(bo, i3.job.id, extractionToForm(d3.extraction.fields)); + ok((await prisma.customer.findUniqueOrThrow({ where: { id: c3.customerId } })).companyName === "Kieler Förde Wohnbau AG", "Neukunde angelegt"); + + section("5. Manuelle Anlage und Zusammenführung nur mit Bestätigung"); + const dupErr = await codeOf(createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" })); + ok(dupErr === "conflict", "manuelle Anlage → „Mögliche Dublette“ (conflict)"); + const manual = await createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }, { acknowledgeDuplicates: true }); + ok(manual.status === "active", "„Trotzdem neu anlegen“ möglich"); + await expectCode(() => mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: false } as never), "invalid", "Zusammenführen ohne Bestätigung → invalid"); + await expectCode(() => mergeCustomers(A.ctx.tech, { sourceId: manual.id, targetId: firm.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen"); + await expectCode(() => mergeCustomers(B.ctx.backoffice, { sourceId: foreign.id, targetId: firm.id, confirm: true }), "not_found", "Mandant B kann nicht mit Kunden von A zusammenführen"); + const merged = await mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: true }); + ok(!!merged && (await prisma.customer.findUniqueOrThrow({ where: { id: manual.id } })).mergedIntoId === firm.id, "mit Bestätigung zusammengeführt"); + + section("6. Mandantentrennung und Rollen"); + const fromB = await findDuplicateCustomers(B.ctx.backoffice, { customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", email: "erika@beispiel.example" }); + ok(fromB.every((c) => c.customerId === foreign.id), "Dublettenprüfung in Mandant B findet nur B-Kunden"); + ok((await findDuplicateCustomers(A.ctx.outsider, { customerNumber: "K-50001" })).length === 0, "Monteur ohne Aufträge findet keine Kunden"); + await expectCode(() => getImportDetail(A.ctx.tech, i3.job.id), "forbidden", "Monteur: Prüfmaske → forbidden"); + await expectCode(() => confirmImport(B.ctx.backoffice, i3.job.id, extractionToForm(d3.extraction.fields)), "not_found", "Mandant B: Import von A bestätigen → not_found"); +}); diff --git a/scripts/test-e2e-multiday-order.ts b/scripts/test-e2e-multiday-order.ts new file mode 100644 index 0000000..ddf88c1 --- /dev/null +++ b/scripts/test-e2e-multiday-order.ts @@ -0,0 +1,145 @@ +// L10a E2E §43.3 „Mehrtägiger Auftrag mit Tagesbericht" — zwei Einsatztage, zwei Tagesberichte, Abschluss. +// +// Tag 1 (vorgestern) und Tag 2 (gestern) werden mit echten Zeitstempeln erfasst (Session-Segmente, +// Fotos mit Aufnahmezeit; Notizen/Material werden als „am Einsatztag erfasst" zurückdatiert, weil +// die Services den Erfassungszeitpunkt selbst setzen). Nachweise: Tagesbericht enthält nur die Daten +// seines Tages, Auftrag bleibt offen (daily_report_created → in_progress am nächsten Tag), ein +// Tagesbericht je Tag, Abschlussbericht fasst beide Tage zusammen, Freigabe + Abrechnung, +// Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-e2e-multiday-order.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { endSession, startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { storeFile } from "../src/server/services/documents/store"; +import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { contentOf } from "../src/server/services/reports/common"; +import { listReports } from "../src/server/services/reports/queries"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import type { ServiceCtx } from "../src/server/services/context"; +import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops"; +import { berlinAt, createTenant, dayKey, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-multi-a"; +const SLUG_B = "zz-q-e2e-multi-b"; +const noPdf = { dispatchPdf: async () => undefined }; + +runSuite("E2E mehrtägiger Auftrag", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const DAY1 = dayKey(-2); + const DAY2 = dayKey(-1); + const jpeg = await jpegBytes("Baustelle"); + + const wo = await createWorkOrder(bo, { + title: "Heizungsverteilung erneuern (2 Tage)", + customerId: A.customerId, + siteId: A.siteId, + plannedStart: berlinAt(-2, 7), + plannedEnd: berlinAt(-1, 16), + signatureRequired: true, + applyTemplate: false, + checklistItems: [{ label: "Druckprobe durchgeführt", required: true }], + materials: [{ name: "Kupferrohr 22 mm", articleNumber: "CU-22", plannedQuantity: 20, unit: "m" }], + }); + await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id, A.users.tech2.id] }); + const plan = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: wo.id } }); + + /** Note/material recorded on a past field day (the services stamp "now"; the device was on site then). */ + async function noteOn(ctx: ServiceCtx, when: Date, text: string) { + const { noteId } = await createNote(ctx, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text })); + await prisma.activityNote.update({ where: { id: noteId }, data: { createdAt: when } }); + } + async function photoOn(ctx: ServiceCtx, when: Date, comment: string) { + const up = await storeFieldUpload(ctx, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + await attachPhoto(ctx, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "during", comment, takenAt: when.toISOString() })); + } + + // ---------- Tag 1 ---------- + section(`Tag 1 (${DAY1}): Einsatz und Tagesbericht`); + const tech = A.ctx.tech; + await startSession(tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-2, 7).toISOString() })); + await noteOn(tech, berlinAt(-2, 11), "Tag 1: alte Verteilung demontiert"); + const u1 = await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, materialPlanId: plan.id, quantity: 8, unit: "m", usageStatus: "partially_used", deviationReason: "Rest folgt an Tag 2" })); + await prisma.materialUsage.update({ where: { id: u1.usageId }, data: { createdAt: berlinAt(-2, 12) } }); + await photoOn(tech, berlinAt(-2, 13), "Tag 1 Konsolen"); + await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-2, 15).toISOString() })); + + await expectCode(() => createDailyReport(B.ctx.tech, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found"); + await expectCode(() => createDailyReport(A.ctx.outsider, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht → not_found"); + const d1 = (await createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 })).report; + const c1 = contentOf(d1); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "daily_report_created", "Auftrag → Tagesbericht erstellt (bleibt offen)"); + ok(c1.time.totalMinutes === 480, `Tagesbericht 1: nur Arbeitszeit von Tag 1 (${c1.time.totalMinutes} min)`); + ok(c1.photos.length === 1 && c1.texts.workPerformed.includes("Tag 1") && !c1.texts.workPerformed.includes("Tag 2"), "Tagesbericht 1: Fotos und Tätigkeiten nur von Tag 1"); + ok(c1.materials.used.length === 1 && c1.materials.used[0].deviationReason === "Rest folgt an Tag 2", "Tagesbericht 1: Material von Tag 1 mit Abweichungsgrund"); + await submitReport(tech, { reportId: d1.id }); + await approveReport(A.ctx.lead, { reportId: d1.id }, noPdf); + const d1Approved = await approveReport(bo, { reportId: d1.id }, noPdf); + ok(d1Approved.status === "approved", "Tagesbericht 1 eingereicht, vom Teamleiter geprüft und vom Backoffice freigegeben"); + await expectCode(() => createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 }), "conflict", "zweiter Tagesbericht für denselben Tag → conflict"); + + // ---------- Tag 2 ---------- + section(`Tag 2 (${DAY2}): zweiter Monteur, zweiter Tagesbericht`); + const tech2 = A.ctx.tech2; + const s2 = await startSession(tech2, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-1, 7).toISOString() })); + ok(s2.workOrderStatus === "in_progress", "Arbeitsbeginn am Folgetag → Auftrag wieder in Arbeit"); + await noteOn(tech2, berlinAt(-1, 10), "Tag 2: Heizkreise angeschlossen, Druckprobe bestanden"); + const u2 = await upsertMaterialUsage(tech2, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Kugelhahn 22 mm", quantity: 4, unit: "Stk", usageStatus: "additional", deviationReason: "Absperrung je Heizkreis" })); + await prisma.materialUsage.update({ where: { id: u2.usageId }, data: { createdAt: berlinAt(-1, 11) } }); + await photoOn(tech2, berlinAt(-1, 12), "Tag 2 fertig"); + const item = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: wo.id } }); + await toggleChecklistItem(tech2, checklistTogglePayload.parse({ workOrderId: wo.id, itemId: item.id, checked: true })); + await endSession(tech2, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-1, 14).toISOString() })); + const d2 = (await createDailyReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; + const c2 = contentOf(d2); + ok(c2.time.totalMinutes === 420 && c2.photos.length === 1, `Tagesbericht 2: nur Zeiten/Fotos von Tag 2 (${c2.time.totalMinutes} min)`); + ok(c2.texts.workPerformed.includes("Tag 2") && !c2.texts.workPerformed.includes("Tag 1"), "Tagesbericht 2: nur Tätigkeiten von Tag 2"); + ok(c2.materials.additional.length === 1 && c2.materials.used.length === 0, "Tagesbericht 2: nur Material von Tag 2"); + ok(c2.reportNumber !== c1.reportNumber && d2.lineageId !== d1.lineageId, `eigene Berichtsnummer je Tag (${c1.reportNumber}, ${c2.reportNumber})`); + await submitReport(tech2, { reportId: d2.id }); + + // ---------- Abschluss ---------- + section("Abschlussbericht über beide Tage, Freigabe, Abrechnung"); + const completion = (await createCompletionReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report; + const cc = contentOf(completion); + ok(cc.workDates.join(",") === `${DAY1},${DAY2}` && cc.time.totalMinutes === 900, `Abschlussbericht: beide Einsatztage, Gesamtzeit ${cc.time.totalMinutes} min`); + ok(cc.photos.length === 2 && cc.materials.used.length === 1 && cc.materials.additional.length === 1, "Abschlussbericht: alle Fotos und Materialien"); + await updateReportTexts(tech2, { reportId: completion.id, texts: { workPerformed: "Heizungsverteilung in zwei Tagen erneuert, Druckprobe bestanden." } }); + const img = await storeFile(tech2, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } }); + await captureSignature(tech2, { reportId: completion.id, outcome: "signed", signerName: "Frau Prüf", imageDocumentId: img.id, confirmationText: "Bestätigt." }); + await submitReport(tech2, { reportId: completion.id }); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Auftrag nach Abschluss „Zur Prüfung“"); + for (const r of [d2.id, completion.id]) { + await approveReport(A.ctx.lead, { reportId: r }, noPdf); + await approveReport(bo, { reportId: r }, noPdf); + } + const released = await releaseForBilling(bo, { workOrderId: wo.id }); + ok(released.status === "released_for_billing", "zur Abrechnung freigegeben"); + + const reports = await listReports(bo, { status: "all" }); + const mine = reports.items.filter((r) => r.workOrder.id === wo.id); + ok(mine.filter((r) => r.type === "daily").length === 2 && mine.filter((r) => r.type === "completion").length === 1, "Berichtsliste: 2 Tagesberichte + 1 Abschlussbericht"); + ok((await listReports(B.ctx.backoffice, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Mandant B sieht keine Berichte von A"); + ok((await listReports(A.ctx.outsider, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Monteur ohne Zuweisung sieht keine Berichte des Auftrags"); + + const hist = await getSiteHistory(bo, A.siteId); + const entry = hist.items.find((i) => i.workOrderId === wo.id); + ok(!!entry && entry.approvedReports.length === 3 && entry.workDone.length === 2, "Objekt-Historie: 3 freigegebene Berichte, Tätigkeiten beider Tage"); + ok(!!entry && entry.date.getTime() === berlinAt(-2, 7).getTime(), "Objekt-Historie: Einsatzbeginn = erster Einsatztag"); +}); diff --git a/scripts/test-e2e-offline-sync.ts b/scripts/test-e2e-offline-sync.ts new file mode 100644 index 0000000..de9e7a2 --- /dev/null +++ b/scripts/test-e2e-offline-sync.ts @@ -0,0 +1,153 @@ +// L10a E2E §43.3 „Offline-Erfassung und Synchronisation" — derselbe Pfad wie POST /api/v1/sync: +// ein offline gesammelter Batch (12 Ops, 2 Tage alt) → alle applied; Wiederholung → duplicate; +// Pflichtfoto fehlt → blocked; veraltete Statusänderung → conflict (nichts überschrieben, Event +// sync.failed, Backoffice-Konfliktliste) → Backoffice übernimmt bzw. verwirft; ungültige/nicht +// verfügbare Ops; fremde clientOpId; Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-e2e-offline-sync.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { getFieldBundle } from "../src/server/services/field/queries"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-sync-a"; +const SLUG_B = "zz-q-e2e-sync-b"; + +function op(opType: SyncOperationInput["opType"], payload: Record, extra: Partial = {}, at = new Date()): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: at.toISOString(), ...extra }; +} + +runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + const created = await createWorkOrder(bo, { + title: "Offline-Einsatz Tiefgarage", + customerId: A.customerId, + siteId: A.siteId, + plannedStart: new Date(), + applyTemplate: false, + checklistItems: [{ label: "Absperrung gesetzt", required: true }], + photoRequirements: [{ key: "fertige_montage", label: "Fertige Montage" }], + materials: [{ name: "Brandschutzmanschette", plannedQuantity: 4, unit: "Stk" }], + }); + await assignWorkOrder(bo, { workOrderId: created.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + + section("1. Vorab-Download und Offline-Erfassung"); + const bundle = await getFieldBundle(tech); + const offline = bundle.orders.find((o) => o.id === created.id)!; + ok(!!offline, "Auftrag im Offline-Bundle des Monteurs"); + const photoDoc = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: created.id, kind: "photo" }, { bytes: await jpegBytes("offline"), name: "offline.jpg", type: "image/jpeg" }); + const t0 = Date.now() - 2 * 86400_000; + const at = (min: number) => new Date(t0 + min * 60_000); + const ids = { travel: randomUUID(), note1: randomUUID(), note2: randomUUID(), extra: randomUUID(), photo: randomUUID() }; + const w = created.id; + const batch: SyncOperationInput[] = [ + op("work_order.transition", { workOrderId: w, to: "accepted" }, { baseVersion: offline.version }, at(0)), + op("session.start", { workOrderId: w, mode: "travel", clientId: ids.travel, offline: true, at: at(1).toISOString() }, {}, at(1)), + op("session.start", { workOrderId: w, mode: "work", at: at(30).toISOString() }, {}, at(30)), + op("note.create", { workOrderId: w, clientId: ids.note1, kind: "work_done", text: "Manschetten gesetzt (offline)" }, {}, at(40)), + op("checklist.toggle", { workOrderId: w, itemId: offline.checklistItems[0].id, checked: true }, {}, at(41)), + op("material.upsert", { workOrderId: w, materialPlanId: offline.materialPlans[0].id, quantity: 4, unit: "Stk", usageStatus: "fully_used" }, {}, at(42)), + op("material.upsert", { workOrderId: w, clientId: ids.extra, name: "Brandschutzkitt", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Fuge zu breit" }, {}, at(43)), + op("photo.attach", { workOrderId: w, clientId: ids.photo, documentId: photoDoc.documentId, phase: "during" }, {}, at(44)), + op("session.pause", { workOrderId: w, at: at(60).toISOString() }, {}, at(60)), + op("session.resume", { workOrderId: w, at: at(75).toISOString() }, {}, at(75)), + op("note.create", { workOrderId: w, clientId: ids.note2, kind: "general", text: "Keine Auffälligkeiten" }, {}, at(80)), + op("session.end", { workOrderId: w, at: at(120).toISOString() }, {}, at(120)), + ]; + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 0, "offline: noch nichts auf dem Server"); + + section("2. Verbindung da: ein Batch"); + const res = await applyOperations(tech, { deviceId: "e2e-device", operations: batch }); + const statuses = res.results.map((x) => x.status); + ok(statuses.every((s) => s === "applied"), `alle 12 Ops applied (${statuses.join(",")})`); + if (!statuses.every((s) => s === "applied")) console.log(res.results.filter((x) => x.status !== "applied")); + ok(!!res.results[1].idMap?.[ids.travel] && !!res.results[3].idMap?.[ids.note1] && !!res.results[7].idMap?.[ids.photo], "idMap bildet Client-IDs auf Server-IDs ab"); + ok(typeof res.results[0].entityVersion === "number" && res.results[0].entityVersion! > offline.version, "Statusänderung liefert neue Auftragsversion"); + const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + ok(wo1.status === "in_progress", `Auftragsstatus nach Sync: in Arbeit (${wo1.status})`); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2 && (await prisma.photo.count({ where: { workOrderId: w } })) === 1, "Notizen, Material und Foto angelegt"); + const session = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: w }, include: { entries: { orderBy: { startedAt: "asc" } } } }); + ok(session.status === "ended" && session.startedOffline && session.entries.map((e) => e.type).join(",") === "travel,work,break,work", "Session offline gestartet, Segmente Anfahrt/Arbeit/Pause/Arbeit"); + ok(session.startedAt.getTime() === at(1).getTime() && session.endedAt?.getTime() === at(120).getTime(), "Zeitstempel des Geräts übernommen (2 Tage alt)"); + const stored = await prisma.syncOperation.findMany({ where: { tenantId: A.tenantId, clientOpId: { in: batch.map((b) => b.clientOpId) } } }); + ok(stored.length === 12 && stored.every((s) => s.status === "applied" && s.userId === A.users.tech.id), "12 SyncOperations protokolliert"); + + section("3. Wiederholung (Verbindungsabbruch nach dem Senden)"); + const replay = await applyOperations(tech, { deviceId: "e2e-device", operations: batch }); + ok(replay.results.every((x) => x.status === "duplicate"), "gleicher Batch erneut → 12× duplicate"); + ok(replay.results[3].idMap?.[ids.note1] === res.results[3].idMap?.[ids.note1], "duplicate liefert gespeicherte idMap"); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2, "keine Doppelanlagen"); + const stolen = await applyOperations(A.ctx.tech2, { deviceId: "other", operations: [batch[3]] }); + ok(stolen.results[0].status === "rejected" && stolen.results[0].errorCode === "invalid", "fremde clientOpId eines anderen Nutzers → rejected"); + + section("4. Pflichtfoto fehlt → Abschluss offline abgelehnt"); + const blocked = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "technically_completed" }, { baseVersion: wo1.version })] }); + ok(blocked.results[0].status === "rejected" && blocked.results[0].errorCode === "blocked" && /photo_requirement/.test(blocked.results[0].message ?? ""), "technisch abschließen ohne Pflichtfoto → rejected blocked (Blocker-Liste)"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "in_progress", "Status unverändert"); + + section("5. Konflikt: Büro ändert den Auftrag während der Monteur offline ist"); + const stale = (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).version; + await updateWorkOrder(bo, w, { technicianNotes: "Bitte zusätzlich Revisionsklappe prüfen" }); + const conflictBatch = [ + op("work_order.transition", { workOrderId: w, to: "waiting_material" }, { baseVersion: stale }), + op("note.create", { workOrderId: w, clientId: randomUUID(), kind: "problem", text: "Material fehlt" }), + ]; + const conflict = await applyOperations(tech, { deviceId: "e2e-device", operations: conflictBatch }); + ok(conflict.results[0].status === "conflict" && conflict.results[0].errorCode === "conflict", "veraltete Statusänderung → conflict"); + ok(conflict.results[1].status === "applied", "unabhängige Notiz trotzdem applied"); + const wo2 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + ok(wo2.status === "in_progress" && wo2.technicianNotes === "Bitte zusätzlich Revisionsklappe prüfen", "nichts überschrieben (Status + Büroänderung erhalten)"); + const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: conflictBatch[0].clientOpId } }); + ok(conflictOp.status === "conflict" && conflictOp.baseVersion === stale, "Konflikt mit Basisversion gespeichert"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.tech.id } })) >= 1, "Monteur wird über den Konflikt informiert"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.backoffice.id } })) >= 1, "Backoffice wird über den Konflikt informiert"); + const list = await listSyncConflicts(bo); + ok(list.some((c) => c.id === conflictOp.id && c.workOrder?.id === w && c.userName === `tech ${SLUG_A}`), "Konflikt in der Backoffice-Liste (Auftrag, Gerätenutzer)"); + await expectCode(() => listSyncConflicts(tech), "forbidden", "Monteur: Konfliktliste → forbidden"); + + section("6. Backoffice löst Konflikte"); + await expectCode(() => applySyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht übernehmen"); + await expectCode(() => discardSyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht verwerfen"); + ok(!(await listSyncConflicts(B.ctx.backoffice)).some((c) => c.id === conflictOp.id), "Mandant B sieht den Konflikt nicht"); + await applySyncConflict(bo, conflictOp.id); + const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } }); + const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } }); + ok(wo3.status === "waiting_material" && resolved.status === "applied" && resolved.resolvedById === A.users.backoffice.id, "Übernehmen: Änderung als Gerätenutzer angewendet, Konflikt erledigt"); + const second = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "in_progress" }, { baseVersion: stale })] }); + const secondOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: second.results[0].clientOpId } }); + ok(second.results[0].status === "conflict", "zweiter veralteter Stand → conflict"); + await discardSyncConflict(bo, secondOp.id); + ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: secondOp.id } })).errorCode === "discarded" && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Verwerfen: nichts angewendet, als verworfen markiert"); + await expectCode(() => discardSyncConflict(bo, secondOp.id), "not_found", "erledigter Konflikt kann nicht erneut bearbeitet werden"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "sync_operation", entityId: { in: [conflictOp.id, secondOp.id] } } })) >= 4, "Konflikte und Lösungen auditiert"); + + section("7. Ungültige Ops, Scope, Mandantentrennung"); + const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] }); + ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid"); + // signature.capture is not registered in services/sync/external-ops.ts (reports lane uses server actions) + const unavailable = op("signature.capture", { reportId: "x" }); + const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] }); + ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, "nicht verfügbare Op → rejected, nicht gespeichert (später wiederholbar)"); + const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } }); + const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] }); + ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found"); + const foreign = await applyOperations(B.ctx.tech, { deviceId: "b", operations: [op("note.create", { workOrderId: w, text: "Mandant B" }), op("work_order.transition", { workOrderId: w, to: "cancelled", reason: "x" }, { baseVersion: wo3.version })] }); + ok(foreign.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Mandant B → not_found (kein Versions-Leak)"); + ok(foreign.results.every((x) => x.entityVersion === undefined), "Mandant B erhält keine Versionsnummer"); + await expectCode(() => storeFieldUpload(B.ctx.tech, { clientId: randomUUID(), workOrderId: w, kind: "photo" }, { bytes: Buffer.from("xx"), name: "b.jpg", type: "image/jpeg" }), "not_found", "Mandant B kann keine Datei an den Auftrag hochladen"); + ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === notesBefore && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Auftrag von A unverändert"); + ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === w), "Mandant B erhält den Auftrag nicht im Bundle"); +}); diff --git a/scripts/test-e2e-regular-order.ts b/scripts/test-e2e-regular-order.ts new file mode 100644 index 0000000..3e2248c --- /dev/null +++ b/scripts/test-e2e-regular-order.ts @@ -0,0 +1,240 @@ +// L10a E2E §43.3 / §38 „Regulärer Auftrag vom Import bis zur Freigabe" — Service-Ebene gegen die DB. +// +// Import (Fake-Provider) → Dublettenkandidat + Objektkandidat → Prüfung/Bestätigung → Zuweisung → +// Vorab-Download (Bundle) → Annehmen/Losfahren/Arbeit → Material/Fotos/Zeiten/Notizen/Checkliste → +// Abschlussbericht → Unterschrift → Teamleiter- und Backoffice-Freigabe → PDF (skip-fähig ohne +// Chromium) → Abrechnung → abgerechnet → Objekt-Historie. Dazu Mandantentrennung (Mandant B) und +// Rollen/Scope (Monteur ohne Zuweisung) an jedem Schritt. +// +// Lauf: npx tsx scripts/test-e2e-regular-order.ts (auch mit RLS_ENFORCED=true lauffähig) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { WorkOrderExtraction } from "../src/server/ai/providers"; +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 { confirmImport } from "../src/server/services/imports/confirm"; +import { getImportDetail } from "../src/server/services/imports/queries"; +import { extractionToForm } from "../src/lib/imports/review"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; +import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { storeFile } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess } from "../src/server/services/documents/access"; +import { createCompletionReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { generateReportPdf } from "../src/server/services/reports/pdf"; +import { openReportFile } from "../src/server/services/reports/files"; +import { contentOf, requireVisibleReport } from "../src/server/services/reports/common"; +import { pdfRendererAvailable } from "../src/server/pdf/render"; +import { readStoredBytes } from "../src/server/services/documents/read"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { + checklistTogglePayload, + materialUpsertPayload, + noteCreatePayload, + photoAttachPayload, + sessionControlPayload, + sessionStartPayload, +} from "../src/lib/sync/ops"; +import { buildPdf } from "./make-sample-pdfs"; +import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-reg-a"; +const SLUG_B = "zz-q-e2e-reg-b"; + +runSuite("E2E regulärer Auftrag", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const tech = A.ctx.tech; + + // ---------- 1. Import ---------- + section("1. Import einer Auftragsbestätigung (Fake-Provider)"); + const pdf = buildPdf([[{ text: "Auftragsbestätigung AB-2026-4711" }, { text: `Hausverwaltung ${SLUG_A} GmbH, Hafenstraße 12, 20457 Hamburg` }]]); + const extraction: Partial = { + orderNumber: { value: "AB-2026-4711", confidence: 0.97 }, + customerNumber: { value: `K-${SLUG_A}`, confidence: 0.95 }, + companyName: { value: `Hausverwaltung ${SLUG_A} GmbH`, confidence: 0.95 }, + customerAddress: { value: { street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.9 }, + siteName: { value: `Wohnanlage ${SLUG_A}`, confidence: 0.75, source: "Bauvorhaben" }, + siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 }, + title: { value: "Austausch Heizungspumpe", confidence: 0.88 }, + plannedStart: { value: "01.10.2026", confidence: 0.9 }, + plannedEnd: { value: "02.10.2026", confidence: 0.9 }, + positions: { + value: [ + { name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", quantity: 1, unit: "Stk", isMaterial: true }, + { name: "Montagearbeiten", quantity: 3, unit: "Std", isMaterial: false }, + ], + confidence: 0.86, + }, + }; + const dispatched: unknown[] = []; + const job = await createImport(bo, { bytes: pdf, fileName: "AB-2026-4711.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) }); + ok(job.status === "uploaded" && dispatched.length === 1, "Upload angenommen, Extraktion eingereiht"); + const processed = await processImport(bo, job.id, { provider: new FakeExtractionProvider({ extraction, text: "AB-2026-4711" }), loadBytes: async () => pdf }); + ok(processed.status === "review_required", "Extraktion → Prüfung erforderlich"); + ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "kein Auftrag ohne Bestätigung"); + await expectCode(() => getImportDetail(B.ctx.backoffice, job.id), "not_found", "Mandant B sieht den Import von A nicht"); + await expectCode(() => getImportDetail(tech, job.id), "forbidden", "Monteur hat keinen Zugriff auf die Prüfmaske"); + + // ---------- 2. Prüfung / Bestätigung ---------- + section("2. Prüfung: bestehender Kunde + bestehendes Objekt zugeordnet"); + const detail = await getImportDetail(bo, job.id); + ok(detail.customerCandidates[0]?.customerId === A.customerId, "Dublettenprüfung schlägt den bestehenden Kunden vor"); + const siteCandidates = (detail.extraction as unknown as { siteCandidates?: { siteId: string }[] }).siteCandidates ?? []; + ok(siteCandidates.some((s) => s.siteId === A.siteId), "bestehendes Objekt an gleicher Adresse als Kandidat"); + const form = extractionToForm(detail.extraction.fields, { customerId: A.customerId, siteId: A.siteId }); + form.order!.title = "Heizungspumpe Wohnanlage tauschen"; + const confirmed = await confirmImport(bo, job.id, form); + const wo0 = await prisma.workOrder.findUniqueOrThrow({ where: { id: confirmed.workOrderId }, include: { materialPlans: true } }); + ok(wo0.status === "planned" && wo0.customerId === A.customerId && wo0.siteId === A.siteId && wo0.sourceImportId === job.id, "Auftrag geplant, Kunde/Objekt zugeordnet, Import verknüpft"); + ok(wo0.materialPlans.length === 1 && wo0.materialPlans[0].articleNumber === "HP-2560", "Materialvorgabe aus Positionen übernommen"); + const woId = wo0.id; + + // Backoffice ergänzt Pflichtpunkt + Pflichtfoto. + const item = await addChecklistItem(bo, woId, { label: "Anlage entlüftet", required: true }); + await addPhotoRequirement(bo, woId, { key: "typenschild", label: "Typenschild neue Pumpe" }); + + // ---------- 3. Zuweisung ---------- + section("3. Zuweisung an Team Nord"); + await expectCode(() => assignWorkOrder(tech, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }), "forbidden", "Monteur darf nicht zuweisen"); + await expectCode(() => assignWorkOrder(B.ctx.backoffice, { workOrderId: woId, teamId: B.teamId }), "not_found", "Mandant B kann den Auftrag von A nicht zuweisen"); + const assigned = await assignWorkOrder(bo, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }); + ok(assigned.status === "assigned", "Auftrag zugewiesen"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "work_order.assigned", entityId: woId } })) === 1, "Monteur erhält Benachrichtigung „Auftrag zugewiesen“"); + + // ---------- 4. Vorab-Download ---------- + section("4. Team lädt Auftrag und Dokumente (Bundle)"); + const bundle = await getFieldBundle(tech); + const bundled = bundle.orders.find((o) => o.id === woId); + ok(!!bundled && bundled.site?.accessNotes === "Schlüssel beim Hausmeister" && bundled.materialPlans.length === 1 && bundled.photoRequirements.length === 1, "Bundle enthält Auftrag mit Zugangshinweis, Material, Pflichtfoto"); + ok(!(await getFieldBundle(A.ctx.outsider)).orders.some((o) => o.id === woId), "Monteur ohne Zuweisung (anderes Team) erhält den Auftrag nicht"); + ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === woId), "Mandant B erhält den Auftrag nicht"); + ok(!!bundled && !bundled.documents.some((d) => d.category === "order_confirmation"), "interne Auftragsbestätigung (backoffice_only) nicht im Monteur-Bundle"); + await expectCode(() => getFieldOrderDetail(A.ctx.outsider, woId), "not_found", "Auftragsdetail für fremden Monteur → not_found"); + + // ---------- 5. Einsatz ---------- + section("5. Einsatz: Annehmen, Losfahren, Arbeiten, Material, Fotos, Zeiten, Notizen"); + const started = new Date(Date.now() - 3 * 3600_000); + await transitionWorkOrder(tech, { workOrderId: woId, to: "accepted" }); + await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "travel", at: started.toISOString() })); + const workStart = await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work", at: new Date(started.getTime() + 30 * 60_000).toISOString() })); + ok(workStart.workOrderStatus === "in_progress", "Arbeit gestartet → in Arbeit"); + await expectCode(() => startSession(A.ctx.outsider, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "fremder Monteur kann keinen Einsatz starten"); + await expectCode(() => startSession(B.ctx.tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "Mandant B kann keinen Einsatz starten"); + + const jpeg = await jpegBytes("Typenschild"); + const before = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "vorher.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: before.documentId, phase: "before", comment: "Alte Pumpe" })); + await createNote(tech, noteCreatePayload.parse({ workOrderId: woId, kind: "work_done", text: "Alte Pumpe demontiert, neue Pumpe eingesetzt." })); + await pauseSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 90 * 60_000).toISOString() })); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } })).status === "paused", "Pause → Auftrag pausiert"); + await resumeSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 105 * 60_000).toISOString() })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, materialPlanId: wo0.materialPlans[0].id, quantity: 1, unit: "Stk", usageStatus: "fully_used" })); + await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Flachdichtung 1 Zoll", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Alte Dichtungen porös" })); + await expectCode( + () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Ohne Grund", quantity: 1, unit: "Stk", usageStatus: "additional" })), + "invalid", + "Zusatzmaterial ohne Begründung → invalid", + ); + const req = await prisma.photoRequirement.findFirstOrThrow({ where: { workOrderId: woId } }); + const plate = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" }); + await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: plate.documentId, phase: "after", photoRequirementId: req.id })); + await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woId, itemId: item.id, checked: true })); + + await expectCode(() => createCompletionReport(tech, { workOrderId: woId }), "blocked", "Abschlussbericht bei laufender Arbeitszeit → blocked"); + const ended = await endSession(tech, sessionControlPayload.parse({ workOrderId: woId })); + ok(ended.workSeconds > 0 && ended.breakSeconds >= 14 * 60 && ended.travelSeconds >= 29 * 60, `Zeiten berechnet (Arbeit ${Math.round(ended.workSeconds / 60)} min, Pause ${Math.round(ended.breakSeconds / 60)} min, Anfahrt ${Math.round(ended.travelSeconds / 60)} min)`); + + // ---------- 6. Abschlussbericht + Unterschrift ---------- + section("6. Abschlussbericht und Kundenunterschrift"); + const { report } = await createCompletionReport(tech, { workOrderId: woId }); + const content = contentOf(report); + ok(content.photos.length === 2 && content.materials.used.length === 1 && content.materials.additional.length === 1, "Berichts-Snapshot enthält Fotos und Material (Soll/Ist + Zusatz)"); + ok(content.time.totalMinutes > 0 && content.site?.name === `Wohnanlage ${SLUG_A}`, "Berichts-Snapshot enthält Arbeitszeit und Objekt"); + ok(content.texts.workPerformed.includes("Alte Pumpe demontiert"), "„Ausgeführte Leistungen“ aus den Tätigkeitsnotizen vorbelegt"); + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "" } }); + await expectCode(() => submitReport(tech, { reportId: report.id }), "blocked", "Absenden ohne „Ausgeführte Leistungen“ → blocked"); + await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Heizungspumpe getauscht, Anlage entlüftet und geprüft." } }); + const sigImg = await storeFile(tech, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: woId } }); + await captureSignature(tech, { reportId: report.id, outcome: "signed", signerName: "Frau Prüf", signerRole: "Objektbetreuung", imageDocumentId: sigImg.id, confirmationText: "Arbeiten ordnungsgemäß ausgeführt." }); + await expectCode(() => captureSignature(B.ctx.tech, { reportId: report.id, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen"); + await submitReport(tech, { reportId: report.id }); + const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } }); + ok(afterSubmit.status === "in_review", "Unterschrift erfasst → Auftrag „Zur Prüfung“"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "report.submitted", entityId: report.id } })) === 1, "Teamleiter wird zur Prüfung benachrichtigt"); + + // ---------- 7. Freigabe ---------- + section("7. Teamleiter- und Backoffice-Freigabe"); + await expectCode(() => approveReport(tech, { reportId: report.id }, { dispatchPdf: async () => undefined }), "forbidden", "Monteur darf nicht freigeben"); + await expectCode(() => approveReport(B.ctx.backoffice, { reportId: report.id }, { dispatchPdf: async () => undefined }), "not_found", "Mandant B kann nicht freigeben"); + await expectCode(() => releaseForBilling(bo, { workOrderId: woId }), "blocked", "Abrechnung vor Berichtsfreigabe → blocked"); + const teamApproved = await approveReport(A.ctx.lead, { reportId: report.id }, { dispatchPdf: async () => undefined }); + ok(teamApproved.status === "team_approved", "Teamleiter prüft → team_approved"); + const pdfJobs: string[] = []; + const approved = await approveReport(bo, { reportId: report.id }, { dispatchPdf: async (_c, id) => void pdfJobs.push(id) }); + ok(approved.status === "approved" && pdfJobs[0] === report.id, "Backoffice gibt frei → approved, PDF-Job angestoßen"); + await expectCode(() => updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "nachträglich geändert" } }), ["conflict", "invalid", "blocked"], "freigegebener Bericht ist unveränderlich"); + + // ---------- 8. PDF ---------- + section("8. PDF-Arbeitsnachweis"); + const renderer = await pdfRendererAvailable(); + if (!renderer.ok) { + console.log(`⚠ PDF-Erzeugung übersprungen: kein Chromium startbar (${renderer.reason})`); + } else { + const pdfRes = await generateReportPdf(bo, report.id); + const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdfRes.documentId } }); + ok(!pdfRes.skipped && stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF erzeugt, Prüfsumme am Bericht"); + ok(doc.category === "completion_report" && doc.siteId === A.siteId && doc.workOrderId === woId, "PDF als Abschlussbericht an Auftrag und Objekt abgelegt"); + const bytes = await readStoredBytes(doc.storageKey); + if (bytes) ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF"); + await expectCode(() => openReportFile(B.ctx.backoffice, report.id, "pdf"), "not_found", "Mandant B kann das PDF nicht laden"); + await expectCode(() => authorizeDocumentAccess(A.ctx.outsider, doc.id), "not_found", "fremder Monteur kann das PDF nicht laden"); + } + + // ---------- 9. Abrechnung ---------- + section("9. Abrechnungsfreigabe und abgerechnet"); + await expectCode(() => releaseForBilling(tech, { workOrderId: woId }), "forbidden", "Monteur darf nicht zur Abrechnung freigeben"); + await expectCode(() => releaseForBilling(A.ctx.lead, { workOrderId: woId }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben"); + const released = await releaseForBilling(bo, { workOrderId: woId }); + ok(released.status === "released_for_billing", "Auftrag zur Abrechnung freigegeben"); + const billed = await markBilled(bo, { workOrderId: woId }); + ok(billed.status === "billed", "Auftrag abgerechnet"); + ok((await codeOf(transitionWorkOrder(bo, { workOrderId: woId, to: "cancelled", reason: "zu spät" }))) === "invalid", "abgerechneter Auftrag kann nicht storniert werden"); + + const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: woId }, orderBy: { createdAt: "asc" }, select: { toStatus: true } }); + const path = history.map((h) => h.toStatus).join(">"); + ok(path === "planned>assigned>accepted>en_route>in_progress>paused>in_progress>technically_completed>in_review>released_for_billing>billed", `Statushistorie vollständig (${path})`); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: woId } })) >= 10, "jede Statusänderung auditiert"); + const woDetail = await getWorkOrderDetail(bo, woId); + ok(woDetail.id === woId, "Backoffice-Detail lesbar"); + + // ---------- 10. Objekt-Historie ---------- + section("10. Einsatz erscheint in der Objekt-Historie"); + const hist = await getSiteHistory(bo, A.siteId); + const entry = hist.items.find((i) => i.workOrderId === woId); + ok(!!entry && entry.approvedReports.length === 1 && entry.signed && entry.photoCount === 2, "Historie: freigegebener Bericht, Unterschrift, Fotos"); + ok(!!entry && entry.materials.some((m) => m.name === "Hocheffizienzpumpe 25-60") && entry.workDone.length === 1, "Historie: Material und durchgeführte Arbeiten"); + const techHistory = await getSiteHistory(A.ctx.tech2, A.siteId); + ok(techHistory.onlyApproved && techHistory.items.some((i) => i.workOrderId === woId), "Teamkollege sieht den freigegebenen Einsatz in der Objekt-Historie"); + await expectCode(() => getSiteHistory(A.ctx.outsider, A.siteId), "not_found", "Monteur ohne Auftrag am Objekt → not_found"); + await expectCode(() => getSiteHistory(B.ctx.backoffice, A.siteId), "not_found", "Mandant B → not_found"); + await expectCode(() => requireVisibleReport(B.ctx.backoffice, report.id), "not_found", "Mandant B kann den Bericht nicht lesen"); +}); diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts new file mode 100644 index 0000000..9d2212b --- /dev/null +++ b/scripts/test-e2e-tenant-isolation.ts @@ -0,0 +1,379 @@ +// L10a E2E §43.3 „Mandantentrennung" — systematisch über ALLE Fachmodelle und Fachservices. +// +// Teil 1 (Datenzugriff): Für JEDES Modell aus TENANT_MODELS (src/server/backup/topology.ts, Spiegel von +// db.ts) wird in Mandant A eine Zeile angelegt; aus Mandant B (dbForTenant) werden findMany, +// findFirst, findUnique, count, update, updateMany, delete, deleteMany auf genau diese Zeile +// versucht → nichts gelesen, nichts geändert. Ein neues Modell ohne Fixture lässt den Test scheitern. +// create mit fremder tenantId landet im eigenen Mandanten. +// Teil 2 (Postgres-RLS): dieselben Zeilen über die Rolle craftvia_app mit Kontext B → 0 Zeilen sichtbar, +// UPDATE/DELETE wirkungslos; Kontext A sieht die Zeile (skip, falls craftvia_app nicht verbinden kann). +// Teil 3 (Fachservices): jeder id-basierte Lese-/Schreibpfad (Kunden, Objekte, Teams, Aufträge, Einsatz, +// Dokumente, Berichte, Import, Notdienst, Sync, Benachrichtigungen, Audit, Lotse) mit IDs aus A → +// not_found (bzw. invalid bei Referenzen), Listen/Suche/Dashboard enthalten keine A-Daten, A unverändert. +// +// Lauf: npx tsx scripts/test-e2e-tenant-isolation.ts (zusätzlich mit RLS_ENFORCED=true) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { prisma, dbForTenant } from "../src/server/db"; +import { TENANT_MODELS, buildTenantTopology } from "../src/server/backup/topology"; +import { getCustomer, updateCustomer, deleteCustomer, confirmProvisionalCustomer, listCustomers, listCustomerWorkOrders } from "../src/server/services/customers/customers"; +import { createContact } from "../src/server/services/customers/contacts"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { getSite, updateSite, deleteSite, createSite, listSites } from "../src/server/services/sites/sites"; +import { getSiteHistory } from "../src/server/services/sites/history"; +import { getTeam, updateTeam, deleteTeam, listTeams } from "../src/server/services/teams/teams"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { addMaterialPlan } from "../src/server/services/work-orders/materials"; +import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { uploadWorkOrderDocument } from "../src/server/services/work-orders/documents"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { searchAll } from "../src/server/services/work-orders/search"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { startSession } from "../src/server/services/field/sessions"; +import { createNote } from "../src/server/services/field/notes"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { attachPhoto } from "../src/server/services/field/photos"; +import { attachVoiceNote } from "../src/server/services/field/voice"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; +import { storeFile } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess, deleteDocument, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; +import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { submitReport } from "../src/server/services/reports/submit"; +import { approveReport } from "../src/server/services/reports/approve"; +import { rejectReport } from "../src/server/services/reports/reject"; +import { captureSignature } from "../src/server/services/reports/signature"; +import { createNewVersion } from "../src/server/services/reports/new-version"; +import { openReportFile } from "../src/server/services/reports/files"; +import { generateReportPdf } from "../src/server/services/reports/pdf"; +import { requireVisibleReport } from "../src/server/services/reports/common"; +import { getReportDetail, listReports } from "../src/server/services/reports/queries"; +import { getImportDetail, listImports } from "../src/server/services/imports/queries"; +import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm"; +import { getEmergencyReview, confirmEmergencyCustomer, listEmergencyReviews } from "../src/server/services/emergency/review"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { listNotifications, markRead } from "../src/server/services/notifications/inbox"; +import { getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer"; +import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol"; +import { getLotseReportState } from "../src/server/services/lotse/state"; +import { noteCreatePayload, sessionStartPayload, materialUpsertPayload, checklistTogglePayload, photoAttachPayload, voiceAttachPayload } from "../src/lib/sync/ops"; +import type { ServiceCtx } from "../src/server/services/context"; +import { codeOf, createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-q-e2e-iso-a"; +const SLUG_B = "zz-q-e2e-iso-b"; +const MARK = "HACKED-BY-B"; + +type Rows = Record }>; + +/** One row per tenant model in tenant A (owner client) + the field a foreign update would try to change. */ +async function createModelRows(A: TenantFixture): Promise { + const t = A.tenantId; + const uid = A.users.tech.id; + const rows: Rows = {}; + const put = (model: string, id: string, update: Record) => (rows[model] = { id, update }); + + put("User", uid, { name: MARK }); + put("Role", (await prisma.role.findFirstOrThrow({ where: { tenantId: t } })).id, { name: MARK }); + put("AuditLog", (await prisma.auditLog.create({ data: { tenantId: t, action: "create", entity: "zz", entityId: "x", after: { v: 1 } } })).id, { entity: MARK }); + put("MailLog", (await prisma.mailLog.create({ data: { tenantId: t, to: "a@zz-qualitaet.test", template: "zz" } })).id, { status: MARK }); + put("NotificationPreference", (await prisma.notificationPreference.create({ data: { tenantId: t, userId: uid, eventType: "zz.event" } })).id, { eventType: MARK }); + put("AuthToken", (await prisma.authToken.create({ data: { principalType: "tenant_user", principalId: uid, tenantId: t, type: "password_reset", tokenHash: randomUUID(), expiresAt: new Date(Date.now() + 3600_000) } })).id, { type: MARK }); + put("TenantSettings", (await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: t } })).id, { orgName: MARK }); + put("TenantModule", (await prisma.tenantModule.create({ data: { tenantId: t, moduleKey: "lotse", enabled: true } })).id, { moduleKey: MARK }); + put("NumberSequence", (await prisma.numberSequence.create({ data: { tenantId: t, key: "zz", prefix: "Z-" } })).id, { prefix: MARK }); + const orderType = await prisma.orderType.create({ data: { tenantId: t, key: "zz_typ", name: "ZZ Typ" } }); + put("OrderType", orderType.id, { name: MARK }); + put("ChecklistTemplate", (await prisma.checklistTemplate.create({ data: { tenantId: t, name: "ZZ Vorlage" } })).id, { name: MARK }); + put("Customer", A.customerId, { companyName: MARK }); + put("Contact", A.contactId, { name: MARK }); + put("Site", A.siteId, { name: MARK }); + put("Team", A.teamId, { name: MARK }); + put("TeamMember", (await prisma.teamMember.findFirstOrThrow({ where: { tenantId: t, userId: uid } })).id, { validTo: new Date(0) }); + + const wo = await prisma.workOrder.create({ data: { tenantId: t, number: "ZZ-ISO-1", customerId: A.customerId, siteId: A.siteId, title: "Isolation", status: "in_progress", assignedTeamId: A.teamId } }); + put("WorkOrder", wo.id, { title: MARK }); + put("WorkOrderAssignee", (await prisma.workOrderAssignee.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid } })).id, { userId: A.users.tech2.id }); + put("WorkOrderStatusChange", (await prisma.workOrderStatusChange.create({ data: { tenantId: t, workOrderId: wo.id, toStatus: "in_progress", actorId: uid } })).id, { reason: MARK }); + put("ChecklistItem", (await prisma.checklistItem.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); + put("PhotoRequirement", (await prisma.photoRequirement.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); + const plan = await prisma.materialPlan.create({ data: { tenantId: t, workOrderId: wo.id, name: "ZZ", plannedQuantity: new Prisma.Decimal(1), unit: "Stk" } }); + put("MaterialPlan", plan.id, { name: MARK }); + put("MaterialUsage", (await prisma.materialUsage.create({ data: { tenantId: t, workOrderId: wo.id, materialPlanId: plan.id, name: "ZZ", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "fully_used" } })).id, { notes: MARK }); + const session = await prisma.workSession.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid, status: "ended", startedAt: new Date(Date.now() - 3600_000), endedAt: new Date() } }); + put("WorkSession", session.id, { deviceInfo: MARK }); + put("TimeEntry", (await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: session.id, userId: uid, startedAt: session.startedAt, endedAt: new Date() } })).id, { correctionReason: MARK }); + put("ActivityNote", (await prisma.activityNote.create({ data: { tenantId: t, workOrderId: wo.id, authorId: uid, text: "ZZ" } })).id, { text: MARK }); + const doc = (key: string) => + prisma.document.create({ data: { tenantId: t, workOrderId: wo.id, category: "photo", fileName: `${key}.jpg`, storageKey: `${t}/${key}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } }); + const d1 = await doc("iso-photo"); + const d2 = await doc("iso-voice"); + const d3 = await doc("iso-import"); + put("Document", d1.id, { title: MARK }); + put("Photo", (await prisma.photo.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d1.id, takenAt: new Date() } })).id, { comment: MARK }); + put("VoiceNote", (await prisma.voiceNote.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d2.id, recordedAt: new Date() } })).id, { transcript: MARK }); + const report = await prisma.report.create({ data: { tenantId: t, workOrderId: wo.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), content: {} } }); + put("Report", report.id, { rejectionReason: MARK }); + put("Signature", (await prisma.signature.create({ data: { tenantId: t, reportId: report.id, outcome: "later", reason: "ZZ", signedAt: new Date() } })).id, { reason: MARK }); + put("ImportJob", (await prisma.importJob.create({ data: { tenantId: t, documentId: d3.id, status: "uploaded" } })).id, { errorMessage: MARK }); + put("Notification", (await prisma.notification.create({ data: { tenantId: t, userId: uid, type: "zz", title: "ZZ", message: "ZZ" } })).id, { title: MARK }); + put("SyncOperation", (await prisma.syncOperation.create({ data: { tenantId: t, userId: uid, clientOpId: randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } })).id, { errorCode: MARK }); + put("AiGeneration", (await prisma.aiGeneration.create({ data: { tenantId: t, kind: "report_draft", provider: "fake", model: "zz" } })).id, { model: MARK }); + return rows; +} + +type Delegate = Record Promise>; +const lc = (m: string) => m.charAt(0).toLowerCase() + m.slice(1); +const snapshotRow = async (model: string, id: string) => JSON.stringify(await (prisma as unknown as Record)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v)); + +async function threw(fn: () => Promise): Promise { + try { + await fn(); + return false; + } catch { + return true; + } +} + +function defaultRlsUrl(): string { + const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); + base.username = "craftvia_app"; + base.password = "craftvia_app_local"; + return base.toString(); +} + +runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + console.log(`RLS_ENFORCED=${process.env.RLS_ENFORCED === "true" ? "true" : "false"}`); + + // ================= Teil 1: alle Tenant-Modelle ================= + section("Teil 1: jedes Tenant-Modell aus Mandant B unerreichbar"); + const rows = await createModelRows(A); + const missing = TENANT_MODELS.filter((m) => !rows[m]); + ok(missing.length === 0, `Fixture deckt alle ${TENANT_MODELS.length} Tenant-Modelle ab${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`); + const dbB = dbForTenant(B.tenantId) as unknown as Record; + for (const model of TENANT_MODELS) { + const row = rows[model]; + if (!row) continue; + const d = dbB[lc(model)]; + const before = await snapshotRow(model, row.id); + const where = { id: row.id }; + const results = { + findMany: ((await d.findMany({ where })) as unknown[]).length === 0, + findFirst: (await d.findFirst({ where })) === null, + findUnique: (await d.findUnique({ where }).catch(() => null)) === null, + count: (await d.count({ where })) === 0, + updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0, + deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0, + update: await threw(() => d.update({ where, data: row.update })), + delete: await threw(() => d.delete({ where })), + }; + const after = await snapshotRow(model, row.id); + const failed = Object.entries(results).filter(([, v]) => !v).map(([k]) => k); + ok(failed.length === 0 && before === after && after !== "null", `${model}: lesen/ändern/löschen aus B abgewiesen, Zeile unverändert${failed.length ? ` — durchgelassen: ${failed.join(", ")}` : ""}${before !== after ? " — ZEILE GEÄNDERT" : ""}`); + } + const injected = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: A.tenantId, companyName: "Untergeschoben" } }); + ok(injected.tenantId === B.tenantId, "create mit fremder tenantId landet im eigenen Mandanten (B)"); + const auditA = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz" } }); + ok((await threw(() => dbForTenant(B.tenantId).auditLog.update({ where: { id: auditA.id }, data: { after: { v: 2 } } }))) && (await prisma.auditLog.findUniqueOrThrow({ where: { id: auditA.id } })).entity === "zz", "Audit-Log von A aus B nicht manipulierbar"); + + // ================= Teil 2: Postgres-RLS ================= + section("Teil 2: Postgres Row Level Security (Rolle craftvia_app)"); + const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) }); + try { + await app.$queryRawUnsafe("SELECT 1"); + const topo = buildTenantTopology(); + for (const model of TENANT_MODELS) { + const row = rows[model]; + if (!row) continue; + const table = topo.nodes.get(model)!.table; + const res = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id); + const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id); + const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id); + return { seen: seen[0].n, updated, deleted }; + }); + const own = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; + }); + ok(res.seen === 0 && res.updated === 0 && res.deleted === 0 && own === 1, `RLS ${table}: Kontext B sieht/ändert/löscht nichts, Kontext A sieht die Zeile`); + } + const noCtx = (await app.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "work_orders" WHERE tenant_id = $1`, A.tenantId))[0].n; + ok(noCtx === 0, "RLS ohne Mandantenkontext: 0 Zeilen (fail-closed)"); + } catch (err) { + if (process.env.RLS_TEST_REQUIRED === "true") throw err; + console.log(`⚠ Teil 2 übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`); + } finally { + await app.$disconnect(); + } + + // ================= Teil 3: Fachservices ================= + section("Teil 3: Fachservices mit IDs aus Mandant A"); + // real objects in A (built through the services) + const woA = await createWorkOrder(A.ctx.backoffice, { title: "Isolation real", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, checklistItems: [{ label: "Prüfen", required: false }], materials: [{ name: "Rohr", plannedQuantity: 2, unit: "m" }] }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work", at: new Date(Date.now() - 7200_000).toISOString() })); + const photoA = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("A"), name: "a.jpg", type: "image/jpeg" }); + await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: woA.id, kind: "work_done", text: "Arbeit in A" })); + const dailyA = (await createDailyReport(A.ctx.tech, { workOrderId: woA.id })).report; + const itemA = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: woA.id } }); + const planA = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: woA.id } }); + const teA = await prisma.timeEntry.findFirstOrThrow({ where: { tenantId: A.tenantId, userId: A.users.tech.id, workSession: { workOrderId: woA.id } } }); + const docA = await prisma.document.findUniqueOrThrow({ where: { id: photoA.documentId } }); + const provisional = await prisma.customer.create({ data: { tenantId: A.tenantId, lastName: "Vorläufig", status: "provisional", isProvisional: true } }); + const ids = { ...Object.fromEntries(Object.entries(rows).map(([k, v]) => [k, v.id])), woA: woA.id, dailyA: dailyA.id, docA: docA.id }; + + const watched = async () => + JSON.stringify( + await Promise.all([ + prisma.customer.findUnique({ where: { id: A.customerId } }), + prisma.site.findUnique({ where: { id: A.siteId } }), + prisma.team.findUnique({ where: { id: A.teamId }, include: { members: true } }), + prisma.workOrder.findUnique({ where: { id: woA.id }, include: { assignees: true, checklistItems: true, materialPlans: true, materialUsages: true, notes: true, photos: true } }), + prisma.report.findUnique({ where: { id: dailyA.id } }), + prisma.document.findUnique({ where: { id: docA.id } }), + prisma.importJob.findUnique({ where: { id: rows.ImportJob.id } }), + prisma.timeEntry.findUnique({ where: { id: teA.id } }), + prisma.notification.findUnique({ where: { id: rows.Notification.id } }), + prisma.syncOperation.findUnique({ where: { id: rows.SyncOperation.id } }), + ]), + ); + const beforeAll = await watched(); + + const bo: ServiceCtx = B.ctx.backoffice; + const admin: ServiceCtx = B.ctx.admin; + const tech: ServiceCtx = B.ctx.tech; + const NF = ["not_found"]; + const REF = ["not_found", "invalid"]; + const checks: [string, () => Promise, string[]][] = [ + // Kunden / Objekte / Teams + ["getCustomer", () => getCustomer(bo, A.customerId), NF], + ["updateCustomer", () => updateCustomer(bo, A.customerId, { companyName: MARK }), NF], + ["deleteCustomer", () => deleteCustomer(bo, A.customerId), NF], + ["confirmProvisionalCustomer", () => confirmProvisionalCustomer(bo, provisional.id), NF], + ["createContact (Kunde A)", () => createContact(bo, A.customerId, { name: MARK }), NF], + ["listCustomerWorkOrders (Kunde A)", async () => { const r = await listCustomerWorkOrders(bo, A.customerId); if (JSON.stringify(r).includes(woA.id)) throw new Error("leak"); throw Object.assign(new Error("empty"), { code: "not_found" }); }, NF], + ["mergeCustomers (Ziel A)", () => mergeCustomers(admin, { sourceId: B.customerId, targetId: A.customerId, confirm: true }), NF], + ["getSite", () => getSite(bo, A.siteId), NF], + ["updateSite", () => updateSite(bo, A.siteId, { name: MARK }), NF], + ["deleteSite", () => deleteSite(bo, A.siteId), NF], + ["getSiteHistory", () => getSiteHistory(bo, A.siteId), NF], + ["createSite (Kunde A)", () => createSite(bo, { customerId: A.customerId, name: MARK }), REF], + ["getTeam", () => getTeam(admin, A.teamId), NF], + ["updateTeam", () => updateTeam(admin, A.teamId, { name: MARK }), NF], + ["deleteTeam", () => deleteTeam(admin, A.teamId), NF], + // Aufträge + ["getWorkOrderDetail", () => getWorkOrderDetail(bo, woA.id), NF], + ["updateWorkOrder", () => updateWorkOrder(bo, woA.id, { title: MARK }), NF], + ["transitionWorkOrder", () => transitionWorkOrder(bo, { workOrderId: woA.id, to: "cancelled", reason: MARK }), NF], + ["assignWorkOrder", () => assignWorkOrder(bo, { workOrderId: woA.id, teamId: B.teamId }), NF], + ["cancelWorkOrder", () => cancelWorkOrder(bo, { workOrderId: woA.id, reason: MARK }), NF], + ["addMaterialPlan", () => addMaterialPlan(bo, woA.id, { name: MARK, plannedQuantity: 1, unit: "Stk" }), NF], + ["addChecklistItem", () => addChecklistItem(bo, woA.id, { label: MARK }), NF], + ["addPhotoRequirement", () => addPhotoRequirement(bo, woA.id, { label: MARK }), NF], + ["getCompletionBlockers", () => getCompletionBlockers(bo, woA.id), NF], + ["releaseForBilling", () => releaseForBilling(bo, { workOrderId: woA.id }), NF], + ["markBilled", () => markBilled(bo, { workOrderId: woA.id }), NF], + ["createWorkOrder (Kunde A)", () => createWorkOrder(bo, { title: MARK, customerId: A.customerId }), REF], + ["uploadWorkOrderDocument", async () => uploadWorkOrderDocument(bo, { workOrderId: woA.id, bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "other", visibility: "team" }), NF], + // Einsatz + ["getFieldOrderDetail", () => getFieldOrderDetail(tech, woA.id), NF], + ["startSession", () => startSession(tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })), NF], + ["createNote", () => createNote(tech, noteCreatePayload.parse({ workOrderId: woA.id, text: MARK })), NF], + ["upsertMaterialUsage", () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woA.id, materialPlanId: planA.id, quantity: 9, unit: "m", usageStatus: "fully_used" })), NF], + ["toggleChecklistItem", () => toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woA.id, itemId: itemA.id, checked: true })), NF], + ["storeFieldUpload", async () => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("B"), name: "b.jpg", type: "image/jpeg" }), NF], + ["attachPhoto (Dokument A)", () => attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woA.id, documentId: docA.id })), NF], + ["attachVoiceNote (Dokument A)", () => attachVoiceNote(tech, voiceAttachPayload.parse({ workOrderId: woA.id, documentId: rows.Document.id })), NF], + ["correctTimeEntry", () => correctTimeEntry(admin, { timeEntryId: teA.id, startedAt: new Date(Date.now() - 600_000), reason: MARK }), NF], + // Dokumente + ["authorizeDocumentAccess", () => authorizeDocumentAccess(bo, docA.id), NF], + ["openDocumentContent", () => openDocumentContent(bo, docA.id), NF], + ["updateDocumentMeta", () => updateDocumentMeta(bo, docA.id, { title: MARK }), NF], + ["deleteDocument", () => deleteDocument(bo, docA.id), NF], + ["storeFile (Auftrag A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woA.id } }), NF], + ["storeFile (Version von A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", lineageId: docA.lineageId }), REF], + // Berichte + ["requireVisibleReport", () => requireVisibleReport(bo, dailyA.id), NF], + ["getReportDetail", () => getReportDetail(bo, dailyA.id), NF], + ["createDailyReport", () => createDailyReport(tech, { workOrderId: woA.id }), NF], + ["createCompletionReport", () => createCompletionReport(tech, { workOrderId: woA.id }), NF], + ["updateReportTexts", () => updateReportTexts(tech, { reportId: dailyA.id, texts: { hints: MARK } }), NF], + ["submitReport", () => submitReport(tech, { reportId: dailyA.id }), NF], + ["approveReport", () => approveReport(bo, { reportId: dailyA.id }, { dispatchPdf: async () => undefined }), NF], + ["rejectReport", () => rejectReport(bo, { reportId: dailyA.id, reason: MARK }), NF], + ["captureSignature", () => captureSignature(tech, { reportId: dailyA.id, outcome: "later", reason: MARK, confirmationText: MARK }), NF], + ["createNewVersion", () => createNewVersion(bo, { reportId: dailyA.id }), NF], + ["openReportFile", () => openReportFile(bo, dailyA.id, docA.id), NF], + ["generateReportPdf", () => generateReportPdf(bo, dailyA.id), NF], + ["getLotseReportState", () => getLotseReportState(tech, dailyA.id), NF], + // Import / Notdienst / Sync / Benachrichtigungen / Audit / KI + ["getImportDetail", () => getImportDetail(bo, rows.ImportJob.id), NF], + ["confirmImport", () => confirmImport(bo, rows.ImportJob.id, {}), REF], + ["discardImport", () => discardImport(bo, rows.ImportJob.id), NF], + ["retryImport", () => retryImport(bo, rows.ImportJob.id, { dispatch: async () => undefined }), NF], + ["getEmergencyReview", () => getEmergencyReview(bo, woA.id), NF], + ["confirmEmergencyCustomer", () => confirmEmergencyCustomer(bo, woA.id), NF], + ["applySyncConflict", () => applySyncConflict(bo, rows.SyncOperation.id), NF], + ["discardSyncConflict", () => discardSyncConflict(bo, rows.SyncOperation.id), NF], + ["markRead (Benachrichtigung A)", () => markRead(tech, rows.Notification.id), NF], + ["getAuditEntry", () => getAuditEntry(admin, rows.AuditLog.id), NF], + ["getAiGenerationContent", () => getAiGenerationContent(admin, rows.AiGeneration.id), NF], + ]; + for (const [name, fn, expected] of checks) { + const got = await codeOf(fn); + ok(expected.includes(got), `${name} aus Mandant B → ${got}`); + } + const sync = await applyOperations(tech, { + deviceId: "b", + operations: [ + { clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: woA.id, text: MARK }, clientCreatedAt: new Date().toISOString() }, + { clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: 1, payload: { workOrderId: woA.id, to: "paused" }, clientCreatedAt: new Date().toISOString() }, + ], + }); + ok(sync.results.every((r) => r.status === "rejected" && r.errorCode === "not_found"), "Sync-Ops auf Auftrag von A → rejected not_found"); + + section("Listen, Suche, Dashboard enthalten keine Daten von A"); + const aIds = Object.values(ids); + const noLeak = (label: string, value: unknown) => { + const text = JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)); + const leaked = aIds.filter((id) => text.includes(id)); + ok(leaked.length === 0 && !text.includes(`Hausverwaltung ${SLUG_A}`), `${label}: keine A-Daten${leaked.length ? ` — enthält ${leaked.length} IDs` : ""}`); + }; + noLeak("listWorkOrders", await listWorkOrders(bo, { sort: "plannedStart", dir: "asc", page: 1, pageSize: 100 })); + noLeak("listReports", await listReports(bo, { status: "all" })); + noLeak("listDocuments", await listDocuments(bo, { pageSize: 500 })); + noLeak("listCustomers", await listCustomers(bo, { status: "all", pageSize: 100 })); + noLeak("listSites", await listSites(bo, { status: "all", pageSize: 100 })); + noLeak("listTeams", await listTeams(bo, { includeInactive: true })); + noLeak("searchAll", await searchAll(bo, { q: SLUG_A.slice(0, 12) })); + noLeak("searchAll (Auftragstitel)", await searchAll(bo, { q: "Isolation" })); + noLeak("listImports", await listImports(bo)); + noLeak("listEmergencyReviews", await listEmergencyReviews(bo, { filter: "all" })); + noLeak("listSyncConflicts", await listSyncConflicts(bo)); + noLeak("listNotifications", await listNotifications(tech, {})); + noLeak("queryAuditLog", await queryAuditLog(admin, {})); + noLeak("listAiGenerations", await listAiGenerations(admin)); + noLeak("getFieldBundle", await getFieldBundle(tech)); + const tiles = await getDashboardTiles(bo, {}); + ok(tiles.open === 0 && tiles.running === 0 && tiles.syncConflicts === 0 && tiles.reportsToReview === 0, "Dashboard von B zählt keine Aufträge/Konflikte/Berichte von A"); + + ok((await watched()) === beforeAll, "Daten von Mandant A nach allen Versuchen unverändert"); +});