diff --git a/scripts/lib-stammdaten-fixtures.ts b/scripts/lib-stammdaten-fixtures.ts new file mode 100644 index 0000000..b0e82f0 --- /dev/null +++ b/scripts/lib-stammdaten-fixtures.ts @@ -0,0 +1,140 @@ +// Shared fixtures for the scripts/test-stammdaten-*.ts tests (lane L1 Stammdaten). +// Creates isolated zz-test tenants with the raw owner client and builds ServiceCtx objects with +// the permission sets of the standard roles (src/server/rbac.ts ROLE_DEFS). Not a test itself +// (the runner only picks up files named test-*.ts). + +import "dotenv/config"; +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 function checker(title: string) { + let failures = 0; + const ok = (cond: unknown, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; + }; + /** Expect `fn` to reject with a ServiceError of `code` (and optionally `reason`). */ + const expectServiceError = async (fn: () => Promise, code: ServiceError["code"], msg: string, reason?: string) => { + try { + await fn(); + ok(false, `${msg} — kein Fehler geworfen`); + } catch (err) { + const e = err as ServiceError; + const actualReason = (e.details as { reason?: string } | undefined)?.reason; + const match = e instanceof ServiceError && e.code === code && (!reason || actualReason === reason); + ok(match, `${msg}${match ? "" : ` — erhalten: ${e?.name}/${e?.code ?? ""}/${actualReason ?? ""} ${e?.message ?? ""}`}`); + } + }; + /** Expect `fn` to reject with any error whose name matches (e.g. ZodError). */ + const expectErrorName = async (fn: () => Promise, name: string, msg: string) => { + try { + await fn(); + ok(false, `${msg} — kein Fehler geworfen`); + } catch (err) { + ok((err as Error)?.name === name, `${msg}${(err as Error)?.name === name ? "" : ` — erhalten: ${(err as Error)?.name} ${(err as Error)?.message}`}`); + } + }; + const finish = () => { + console.log(failures === 0 ? `\nOK — ${title}: alle Prüfungen erfüllt.` : `\n${failures} FEHLER in ${title}.`); + return failures; + }; + return { ok, expectServiceError, expectErrorName, finish, get failures() { return failures; } }; +} + +const MODELS_IN_DELETE_ORDER = [ + "signature", + "report", + "photo", + "activityNote", + "voiceNote", + "materialUsage", + "timeEntry", + "workSession", + "materialPlan", + "checklistItem", + "photoRequirement", + "workOrderStatusChange", + "workOrderAssignee", + "document", + "syncOperation", + "notification", + "aiGeneration", + "workOrder", + "importJob", + "site", + "contact", + "customer", + "teamMember", + "team", + "checklistTemplate", + "orderType", + "numberSequence", + "auditLog", +] as const; + +export async function cleanupTenants(slugs: string[], emailDomain: string) { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const client = prisma as unknown as Record Promise }>; + for (const model of MODELS_IN_DELETE_ORDER) await client[model].deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } }); + await prisma.user.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenantModule.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.role.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${emailDomain}` }, memberships: { none: {} } } }); +} + +export async function createTenant(slug: string, name: string) { + return prisma.tenant.create({ data: { slug, name } }); +} + +export async function createUser(tenantId: string, email: string, name: string) { + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE" } }); +} + +/** ServiceCtx with the permission set of a standard role (plus/minus adjustments). */ +export function ctxFor(tenantId: string, userId: string, role: RoleKey, adjust: { add?: string[]; remove?: string[] } = {}): ServiceCtx { + const perms = new Set(ROLE_DEFS[role].permissions); + for (const p of adjust.add ?? []) perms.add(p); + for (const p of adjust.remove ?? []) perms.delete(p); + return { db: dbForTenant(tenantId), tenantId, userId, permissions: perms }; +} + +let orderSeq = 0; +export async function createWorkOrder( + tenantId: string, + data: { customerId: string; siteId?: string | null; assignedTeamId?: string | null; status?: string; title?: string; followUpWork?: string | null; plannedStart?: Date }, +) { + orderSeq++; + return prisma.workOrder.create({ + data: { + tenantId, + number: `ZZ-${Date.now().toString(36)}-${orderSeq}`, + customerId: data.customerId, + siteId: data.siteId ?? null, + assignedTeamId: data.assignedTeamId ?? null, + status: (data.status ?? "assigned") as never, + title: data.title ?? `Testauftrag ${orderSeq}`, + followUpWork: data.followUpWork ?? null, + plannedStart: data.plannedStart, + }, + }); +} + +export async function createTeamWithMember(tenantId: string, name: string, memberUserId: string | null, leaderUserId: string | null = null) { + const team = await prisma.team.create({ data: { tenantId, name, leaderUserId } }); + if (memberUserId) await prisma.teamMember.create({ data: { tenantId, teamId: team.id, userId: memberUserId, validFrom: new Date(Date.now() - 86_400_000) } }); + return team; +} + +export async function disconnect() { + await prisma.$disconnect(); +} + +export { prisma }; diff --git a/scripts/test-stammdaten-customers.ts b/scripts/test-stammdaten-customers.ts new file mode 100644 index 0000000..4c86258 --- /dev/null +++ b/scripts/test-stammdaten-customers.ts @@ -0,0 +1,200 @@ +// L1 Stammdaten — Kunden, Ansprechpartner, vorläufige Kunden, Zusammenführen (Spec §7, US-003): +// (1) Anlage mit Nummernkreis, manuelle Nummer, Eindeutigkeit, Dublettenhinweis, Validierung, Audit +// (2) Bearbeiten, Ansprechpartner, vorläufig → aktiv +// (3) Mandantentrennung: Mandant B kann A weder lesen noch ändern noch zusammenführen +// (4) Rollen/Scope: Monteur ohne Zuweisung → not_found, mit Teamauftrag → sichtbar, keine Schreibrechte +// (5) Zusammenführen: nur mit customer:merge + Bestätigung, hängt Kontakte/Objekte/Aufträge/Dokumente um +// (6) Soft Delete blockiert bei offenen Aufträgen +// +// Lauf: npx tsx scripts/test-stammdaten-customers.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { + confirmProvisionalCustomer, + createCustomer, + deleteCustomer, + getCustomer, + listCustomers, + updateCustomer, +} from "../src/server/services/customers/customers"; +import { createContact, deleteContact, updateContact } from "../src/server/services/customers/contacts"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { + checker, + cleanupTenants, + createTeamWithMember, + createTenant, + createUser, + createWorkOrder, + ctxFor, + disconnect, + prisma, +} from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-cust-a"; +const SLUG_B = "zz-l1-cust-b"; +const DOMAIN = "zz-l1-cust.test"; +const c = checker("Kunden"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Kunden A"); + const tB = await createTenant(SLUG_B, "L1 Kunden B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + const ctxTech = ctxFor(tA.id, techA.id, "technician"); + + console.log("— (1) Anlage —"); + const alpha = await createCustomer(ctxA, { companyName: "Alpha Sanitär GmbH", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg" }); + c.ok(/^K-\d{5}$/.test(alpha.customerNumber ?? ""), `Kundennummer aus Nummernkreis (${alpha.customerNumber})`); + c.ok(alpha.createdById === boA.id && alpha.status === "active" && alpha.country === "DE", "createdById, Status aktiv, Land DE"); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "create" } }); + c.ok(!!audit?.after, "Audit-Eintrag (create, after) geschrieben"); + + const manual = await createCustomer(ctxA, { companyName: "Beta Elektro", customerNumber: "K-00002" }); + c.ok(manual.customerNumber === "K-00002", "manuelle Kundennummer übernommen"); + const auto = await createCustomer(ctxA, { lastName: "Gamma", firstName: "Gerda" }); + c.ok(auto.customerNumber === "K-00003", `Nummernkreis überspringt manuell vergebene Nummer (${auto.customerNumber})`); + await c.expectServiceError(() => createCustomer(ctxA, { companyName: "Delta", customerNumber: "K-00002" }), "conflict", "doppelte Kundennummer → conflict", "number_taken"); + const inB = await createCustomer(ctxB, { companyName: "Delta B", customerNumber: "K-00002" }); + c.ok(inB.customerNumber === "K-00002", "gleiche Kundennummer in anderem Mandanten erlaubt (eindeutig je Mandant)"); + + await c.expectServiceError( + () => createCustomer(ctxA, { companyName: "Alpha Sanitär", city: "Hamburg", street: "Hafen-Str.", houseNumber: "1", postalCode: "20457" }), + "conflict", + "mögliche Dublette ohne Bestätigung → conflict", + "possible_duplicates", + ); + try { + await createCustomer(ctxA, { companyName: "Alpha Sanitär" }); + } catch (err) { + const cands = (err as { details?: { candidates?: { customerId: string }[] } }).details?.candidates ?? []; + c.ok(cands.some((x) => x.customerId === alpha.id), "Dublettenhinweis enthält den bestehenden Kunden"); + } + const ack = await createCustomer(ctxA, { companyName: "Alpha Sanitär" }, { acknowledgeDuplicates: true }); + c.ok(!!ack.id, "nach Bestätigung trotzdem angelegt (Nutzer entscheidet)"); + + await c.expectErrorName(() => createCustomer(ctxA, { city: "Nirgendwo" }), "ZodError", "ohne Firmenname/Nachname → Validierungsfehler"); + await c.expectErrorName(() => createCustomer(ctxA, { companyName: "X", email: "kein-mail" }), "ZodError", "ungültige E-Mail → Validierungsfehler"); + + console.log("\n— (2) Bearbeiten, Ansprechpartner, vorläufig —"); + const updated = await updateCustomer(ctxA, alpha.id, { email: "info@alpha.example", notes: null }); + c.ok(updated.email === "info@alpha.example", "E-Mail geändert"); + const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "update" } }); + c.ok(!!upAudit?.before && !!upAudit?.after, "Audit-Eintrag (update, before/after)"); + await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { companyName: null, lastName: null }), "invalid", "Name darf nicht vollständig entfernt werden", "name_required"); + await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { customerNumber: "K-00002" }), "conflict", "Änderung auf vergebene Nummer → conflict", "number_taken"); + + const contact = await createContact(ctxA, alpha.id, { name: "Petra Planer", role: "Hausverwaltung", phone: "040 111", preferredChannel: "phone" }); + const contact2 = await createContact(ctxA, alpha.id, { name: "Olaf Objekt", email: "olaf@alpha.example", preferredChannel: "email" }); + c.ok(contact.customerId === alpha.id && contact2.preferredChannel === "email", "mehrere Ansprechpartner mit bevorzugtem Kontaktweg"); + const contactUp = await updateContact(ctxA, contact.id, { name: "Petra Planer", preferredChannel: "mobile", mobile: "0170 1" }); + c.ok(contactUp.preferredChannel === "mobile", "Ansprechpartner bearbeitet"); + await c.expectErrorName(() => createContact(ctxA, alpha.id, { name: "X", preferredChannel: "fax" as never }), "ZodError", "ungültiger Kontaktweg → Validierungsfehler"); + await deleteContact(ctxA, contact2.id); + const detail = await getCustomer(ctxA, alpha.id); + c.ok(detail.contacts.length === 1 && detail.contacts[0].id === contact.id, "gelöschter Ansprechpartner ausgeblendet (Soft Delete)"); + + const prov = await createCustomer(ctxA, { lastName: "Notdienstkunde", status: "provisional" }, { acknowledgeDuplicates: true }); + c.ok(prov.status === "provisional" && prov.isProvisional, "vorläufiger Kunde angelegt"); + const provList = await listCustomers(ctxA, { status: "provisional" }); + c.ok(provList.items.some((x) => x.id === prov.id) && provList.items.every((x) => x.status === "provisional"), "Filter „vorläufig“"); + const confirmed = await confirmProvisionalCustomer(ctxA, prov.id); + c.ok(confirmed.status === "active" && !confirmed.isProvisional, "vorläufig → aktiv bestätigt"); + await c.expectServiceError(() => confirmProvisionalCustomer(ctxA, prov.id), "conflict", "erneutes Bestätigen → conflict", "not_provisional"); + + const search = await listCustomers(ctxA, { q: "alpha", pageSize: 1 }); + c.ok(search.total === 2 && search.items.length === 1 && search.pageSize === 1, "Suche + Paginierung"); + + console.log("\n— (3) Mandantentrennung —"); + await c.expectServiceError(() => getCustomer(ctxB, alpha.id), "not_found", "Mandant B liest Kunden von A → not_found"); + await c.expectServiceError(() => updateCustomer(ctxB, alpha.id, { notes: "gehackt" }), "not_found", "Mandant B ändert Kunden von A → not_found"); + await c.expectServiceError(() => createContact(ctxB, alpha.id, { name: "Fremd" }), "not_found", "Mandant B legt Kontakt an Kunde A an → not_found"); + await c.expectServiceError(() => updateContact(ctxB, contact.id, { name: "Fremd" }), "not_found", "Mandant B ändert Kontakt von A → not_found"); + await c.expectServiceError(() => confirmProvisionalCustomer(ctxB, prov.id), "not_found", "Mandant B bestätigt Kunden von A → not_found"); + await c.expectServiceError(() => deleteCustomer(ctxB, alpha.id), "not_found", "Mandant B löscht Kunden von A → not_found"); + const listB = await listCustomers(ctxB, { pageSize: 100 }); + c.ok(listB.items.every((x) => x.id === inB.id), "Liste von B enthält nur eigene Kunden"); + const stillA = await prisma.customer.findUnique({ where: { id: alpha.id } }); + c.ok(stillA?.notes === null && stillA?.email === "info@alpha.example", "Kunde A unverändert"); + + console.log("\n— (4) Rollen/Scope Monteur —"); + await c.expectServiceError(() => getCustomer(ctxTech, alpha.id), "not_found", "Monteur ohne Zuweisung → not_found"); + c.ok((await listCustomers(ctxTech)).total === 0, "Monteur ohne Zuweisung sieht keine Kunden"); + await c.expectServiceError(() => updateCustomer(ctxTech, alpha.id, { notes: "x" }), "forbidden", "Monteur darf Kunden nicht ändern → forbidden"); + await c.expectServiceError(() => createCustomer(ctxTech, { companyName: "Monteurkunde" }), "forbidden", "Monteur darf keine Kunden anlegen → forbidden"); + const team = await createTeamWithMember(tA.id, "Team Nord", techA.id); + const order = await createWorkOrder(tA.id, { customerId: alpha.id, assignedTeamId: team.id }); + const techView = await getCustomer(ctxTech, alpha.id); + c.ok(techView.id === alpha.id, "Monteur sieht Kunden über Auftrag seines Teams"); + c.ok((await listCustomers(ctxTech)).items.map((x) => x.id).join() === alpha.id, "Monteur-Liste nur mit erreichbaren Kunden"); + + console.log("\n— (5) Zusammenführen —"); + const source = await createCustomer(ctxA, { companyName: "Quelle Haustechnik" }); + const target = await createCustomer(ctxA, { companyName: "Ziel Haustechnik" }); + const sContact = await createContact(ctxA, source.id, { name: "Kontakt Quelle" }); + const sSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: source.id, name: "Objekt Quelle" } }); + const sOrder = await createWorkOrder(tA.id, { customerId: source.id, siteId: sSite.id }); + const sDoc = await prisma.document.create({ + data: { tenantId: tA.id, customerId: source.id, category: "other", fileName: "a.pdf", storageKey: `${tA.id}/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: `lin-${source.id}` }, + }); + + await c.expectServiceError( + () => mergeCustomers(ctxFor(tA.id, boA.id, "backoffice", { remove: ["customer:merge"] }), { sourceId: source.id, targetId: target.id, confirm: true }), + "forbidden", + "ohne customer:merge → forbidden", + ); + await c.expectServiceError(() => mergeCustomers(ctxTech, { sourceId: source.id, targetId: target.id, confirm: true }), "forbidden", "Monteur → forbidden"); + await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: false as true }), "ZodError", "ohne Bestätigung → abgelehnt"); + await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: source.id, confirm: true }), "ZodError", "Quelle = Ziel → abgelehnt"); + await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: source.id, targetId: target.id, confirm: true }), "not_found", "Mandant B führt Kunden von A zusammen → not_found"); + const bCustomer = await createCustomer(ctxB, { companyName: "B-Kunde" }); + await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: bCustomer.id, confirm: true }), "not_found", "Ziel aus fremdem Mandanten → not_found (keine Kreuzung)"); + await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: bCustomer.id, targetId: target.id, confirm: true }), "not_found", "Quelle B in Ziel A → not_found"); + c.ok((await prisma.customer.findUnique({ where: { id: source.id } }))?.status === "active", "nach abgelehnten Versuchen ist die Quelle unverändert"); + + const result = await mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }); + c.ok( + result.moved.contacts === 1 && result.moved.sites === 1 && result.moved.workOrders === 1 && result.moved.documents === 1, + `umgehängt: ${JSON.stringify(result.moved)}`, + ); + const [srcAfter, contactAfter, siteAfter, orderAfter, docAfter] = await Promise.all([ + prisma.customer.findUnique({ where: { id: source.id } }), + prisma.contact.findUnique({ where: { id: sContact.id } }), + prisma.site.findUnique({ where: { id: sSite.id } }), + prisma.workOrder.findUnique({ where: { id: sOrder.id } }), + prisma.document.findUnique({ where: { id: sDoc.id } }), + ]); + c.ok(srcAfter?.status === "merged" && srcAfter.mergedIntoId === target.id, "Quelle: status merged + mergedIntoId"); + c.ok(contactAfter?.customerId === target.id && siteAfter?.customerId === target.id && docAfter?.customerId === target.id, "Kontakt, Objekt, Dokument hängen am Ziel"); + c.ok(orderAfter?.customerId === target.id && orderAfter.version === sOrder.version + 1, "Auftrag am Ziel, Version erhöht (Offline-Konflikterkennung)"); + const mergeAudits = await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: { in: [source.id, target.id] }, action: "update" } }); + c.ok(mergeAudits === 2, "volles Audit: Einträge für Quelle und Ziel"); + await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }), "conflict", "erneutes Zusammenführen → conflict", "already_merged"); + c.ok(!(await listCustomers(ctxA, { pageSize: 100 })).items.some((x) => x.id === source.id), "Standardliste blendet zusammengeführte Kunden aus"); + await c.expectServiceError(() => updateCustomer(ctxA, source.id, { notes: "x" }), "not_found", "zusammengeführter Kunde ist nicht mehr bearbeitbar"); + + console.log("\n— (6) Soft Delete —"); + await c.expectServiceError(() => deleteCustomer(ctxA, alpha.id), "blocked", "Löschen bei offenem Auftrag → blocked", "open_work_orders"); + await prisma.workOrder.update({ where: { id: order.id }, data: { status: "cancelled" } }); + const deleted = await deleteCustomer(ctxA, alpha.id); + c.ok(!!deleted.deletedAt, "Kunde soft-gelöscht (deletedAt gesetzt)"); + c.ok(!!(await prisma.customer.findUnique({ where: { id: alpha.id } })), "Datensatz physisch noch vorhanden"); + await c.expectServiceError(() => getCustomer(ctxA, alpha.id), "not_found", "gelöschter Kunde → not_found"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-documents.ts b/scripts/test-stammdaten-documents.ts new file mode 100644 index 0000000..0ca7405 --- /dev/null +++ b/scripts/test-stammdaten-documents.ts @@ -0,0 +1,211 @@ +// L1 Stammdaten — Dokumentenablage (ARCHITEKTUR §4.3, Spec §24, §27.4): +// (1) Scanner/Typprüfung: Magic Bytes, Allowlist, Typ-Mismatch +// (2) Dateinamen-Normalisierung +// (3) storeFile: falscher Magic Byte / leer / zu groß / Sichtbarkeit → abgelehnt; SHA-256, Versionierung +// (4) Download-Autorisierung: backoffice_only für Monteur verweigert, fremder Mandant verweigert, +// Auftrags-Scope, Objekt-Scope, Teamleiter-Sichtbarkeit +// +// Lauf: npx tsx scripts/test-stammdaten-documents.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { createHash } from "node:crypto"; +import { detectMime, MagicByteScanner } from "../src/server/services/documents/scanner"; +import { normalizeFileName, storeFile, SIZE_LIMITS } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess, deleteDocument, getDownloadUrl, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; +import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-doc-a"; +const SLUG_B = "zz-l1-doc-b"; +const DOMAIN = "zz-l1-doc.test"; +const c = checker("Dokumente"); + +const PDF = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n"); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); +const JPEG_HEAD = [0xff, 0xd8, 0xff, 0xe0]; +const EXE = new TextEncoder().encode("MZ\x90\x00this is not an image"); + +async function main() { + console.log("— (1) Scanner —"); + const scanner = new MagicByteScanner(); + c.ok(detectMime(PDF) === "application/pdf" && detectMime(PNG) === "image/png" && detectMime(new Uint8Array(JUMP())) === "image/jpeg", "Magic Bytes PDF/PNG/JPEG erkannt"); + c.ok(detectMime(EXE) === null, "unbekannte Signatur (EXE) → nicht erkannt"); + const okPdf = await scanner.scan({ bytes: PDF, declaredMime: "application/pdf", fileName: "a.pdf" }); + c.ok(okPdf.ok && okPdf.kind === "pdf", "PDF mit passendem Typ akzeptiert"); + const mismatch = await scanner.scan({ bytes: PNG, declaredMime: "application/pdf", fileName: "a.pdf" }); + c.ok(!mismatch.ok && mismatch.reason === "type_mismatch", "PNG-Bytes als PDF deklariert → type_mismatch"); + const exeAsPng = await scanner.scan({ bytes: EXE, declaredMime: "image/png", fileName: "x.png" }); + c.ok(!exeAsPng.ok && exeAsPng.reason === "type_mismatch", "EXE als PNG deklariert → type_mismatch"); + const txt = await scanner.scan({ bytes: PDF, declaredMime: "text/html", fileName: "x.html" }); + c.ok(!txt.ok && txt.reason === "unsupported_type", "nicht erlaubter Typ → unsupported_type"); + const jpgAlias = await scanner.scan({ bytes: new Uint8Array(JUMP()), declaredMime: "image/jpg", fileName: "x.jpg" }); + c.ok(jpgAlias.ok && jpgAlias.detectedMime === "image/jpeg", "MIME-Alias image/jpg → image/jpeg"); + + console.log("\n— (2) Dateinamen —"); + c.ok(normalizeFileName("../../etc/passwd") === "passwd", "Pfadanteile entfernt"); + c.ok(normalizeFileName("C:\\Users\\x\\Plan .pdf") === "Plan _v2_.pdf", "Windows-Pfad und reservierte Zeichen"); + c.ok(normalizeFileName("a\u0000b\u001f.pdf") === "ab.pdf", "Steuerzeichen entfernt"); + c.ok(normalizeFileName(" ") === "datei" && normalizeFileName(".hidden") === "hidden", "leer → „datei“, führender Punkt entfernt"); + const long = normalizeFileName(`${"x".repeat(300)}.pdf`); + c.ok(long.length === 180 && long.endsWith(".pdf"), "Länge begrenzt, Endung erhalten"); + c.ok(normalizeFileName("Grundriss Erdgeschoß.pdf") === "Grundriss Erdgeschoß.pdf", "Umlaute und Leerzeichen bleiben lesbar"); + + console.log("\n— (3) storeFile —"); + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Dokumente A"); + const tB = await createTenant(SLUG_B, "L1 Dokumente B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); + const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); + const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Teamleiter X"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); + const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); + const ctxLead = ctxFor(tA.id, lead.id, "team-lead"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Dokukunde" } }); + const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Objekt mit Plänen" } }); + const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id, lead.id); + const teamY = await createTeamWithMember(tA.id, "Team Y", null); + const woX = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id }); + const woY = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: teamY.id }); + + await c.expectServiceError( + () => storeFile(ctxA, { bytes: PNG, fileName: "plan.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", links: { siteId: site.id } }), + "invalid", + "falscher Magic Byte (PNG als PDF) → abgelehnt", + "type_mismatch", + ); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: EXE, fileName: "virus.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "EXE mit .pdf-Endung → abgelehnt", + "type_mismatch", + ); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: new Uint8Array(0), fileName: "leer.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "leere Datei → abgelehnt", + "empty_file", + ); + const bigJpeg = new Uint8Array(SIZE_LIMITS.image + 1); + bigJpeg.set(JUMP()); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: bigJpeg, fileName: "gross.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { siteId: site.id } }), + "invalid", + "Bild über 15 MB → abgelehnt", + "too_large", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "intern.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }), + "invalid", + "Monteur darf keine backoffice_only-Datei ablegen", + "visibility_not_allowed", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "objekt.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "forbidden", + "Monteur ohne document:write darf nicht am Objekt ablegen", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "fremd.pdf", declaredMime: "application/pdf", category: "photo", visibility: "team", links: { workOrderId: woY.id } }), + "not_found", + "Monteur legt an nicht sichtbarem Auftrag ab → not_found", + ); + await c.expectServiceError( + () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "Mandant B legt an Objekt von A ab → abgelehnt", + "site_not_found", + ); + + const planV1 = await storeFile(ctxA, { bytes: PDF, fileName: "../Grundriss EG.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", title: "Grundriss EG", links: { siteId: site.id } }); + c.ok(planV1.version === 1 && planV1.fileName === "Grundriss EG.pdf" && planV1.mimeType === "application/pdf", "PDF gespeichert, Name normalisiert"); + c.ok(planV1.checksum === createHash("sha256").update(PDF).digest("hex") && planV1.fileSize === PDF.byteLength, "SHA-256-Prüfsumme und Größe"); + c.ok(planV1.storageKey.startsWith(`${tA.id}/`) || planV1.storageKey.startsWith("stub://"), "Storage-Key mandantenpräfixiert"); + c.ok(planV1.uploadedById === boA.id, "Ersteller gespeichert"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "document", entityId: planV1.id, action: "create" } })), "Audit create"); + const planV2 = await storeFile(ctxA, { bytes: PDF, fileName: "Grundriss EG v2.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", lineageId: planV1.lineageId }); + c.ok(planV2.version === 2 && planV2.lineageId === planV1.lineageId && planV2.siteId === site.id, "neue Version: version 2, gleiche lineage, Zuordnung übernommen"); + await c.expectServiceError( + () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", lineageId: planV1.lineageId }), + "invalid", + "Mandant B kann keine Version eines A-Dokuments anlegen", + "lineage_not_found", + ); + + console.log("\n— (4) Download-Autorisierung —"); + const internal = await storeFile(ctxA, { bytes: PDF, fileName: "kalkulation.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }); + const leadOnly = await storeFile(ctxA, { bytes: PDF, fileName: "teamleitung.pdf", declaredMime: "application/pdf", category: "other", visibility: "team_lead", links: { workOrderId: woX.id } }); + const teamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "montage.pdf", declaredMime: "application/pdf", category: "assembly_instructions", visibility: "team", links: { workOrderId: woX.id } }); + const otherTeamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "fremdteam.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: woY.id } }); + const photo = await storeFile(ctxT1, { bytes: new Uint8Array(JUMP()), fileName: "foto.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woX.id } }); + c.ok(photo.uploadedById === tech1.id, "Monteur legt Foto an sichtbarem Auftrag ab (field:execute)"); + + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, internal.id), "not_found", "backoffice_only für Monteur verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, leadOnly.id), "not_found", "team_lead-Dokument für Monteur verweigert"); + c.ok((await authorizeDocumentAccess(ctxLead, leadOnly.id)).id === leadOnly.id, "team_lead-Dokument für Teamleiter erlaubt"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxLead, internal.id), "not_found", "backoffice_only auch für Teamleiter verweigert"); + c.ok((await authorizeDocumentAccess(ctxT1, teamDoc.id)).id === teamDoc.id, "Team-Dokument am eigenen Auftrag erlaubt"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, otherTeamDoc.id), "not_found", "Team-Dokument an fremdem Auftrag verweigert (Auftrags-Scope)"); + c.ok((await authorizeDocumentAccess(ctxT1, planV2.id)).id === planV2.id, "Objekt-Dokument erlaubt, wenn Objekt über Teamauftrag erreichbar"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, planV2.id), "not_found", "Objekt-Dokument für Monteur ohne Zuweisung verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, teamDoc.id), "not_found", "Auftrags-Dokument für Monteur ohne Zuweisung verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxB, teamDoc.id), "not_found", "fremder Mandant verweigert (Team-Dokument)"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxB, internal.id), "not_found", "fremder Mandant verweigert (Backoffice-Dokument)"); + await c.expectServiceError(() => openDocumentContent(ctxB, planV1.id), "not_found", "fremder Mandant erhält keinen Inhalt"); + c.ok((await authorizeDocumentAccess(ctxA, internal.id)).id === internal.id, "Backoffice mit document:read_internal liest backoffice_only"); + await c.expectServiceError( + () => authorizeDocumentAccess(ctxFor(tA.id, boA.id, "backoffice", { remove: ["document:read"] }), teamDoc.id), + "not_found", + "ohne document:read → verweigert", + ); + c.ok((await getDownloadUrl(ctxT1, teamDoc.id)) === `/files/${teamDoc.id}`, "Download-Link ist die interne Route /files/"); + + const t1List = await listDocuments(ctxT1, { pageSize: 100 }); + const t1Ids = new Set(t1List.items.map((d) => d.id)); + c.ok(t1Ids.has(teamDoc.id) && t1Ids.has(planV2.id) && t1Ids.has(photo.id), "Monteur-Liste enthält sichtbare Dokumente"); + c.ok(!t1Ids.has(internal.id) && !t1Ids.has(leadOnly.id) && !t1Ids.has(otherTeamDoc.id), "Monteur-Liste ohne interne/fremde Dokumente"); + const latest = await listDocuments(ctxA, { siteId: site.id, latestOnly: true }); + c.ok(latest.items.some((d) => d.id === planV2.id) && !latest.items.some((d) => d.id === planV1.id), "latestOnly zeigt nur die neueste Version"); + const byCustomer = await listDocuments(ctxA, { customerId: customer.id, pageSize: 100 }); + c.ok(byCustomer.items.some((d) => d.id === planV1.id) && byCustomer.items.some((d) => d.id === teamDoc.id), "Filter Kunde umfasst Objekt- und Auftragsdokumente"); + c.ok((await listDocuments(ctxB, { pageSize: 100 })).total === 0, "Mandant B sieht keine Dokumente von A"); + + if (planV1.storageKey.startsWith(`${tA.id}/`)) { + const { content } = await openDocumentContent(ctxT1, planV1.id); + const buf = Buffer.from(await new Response(content.stream).arrayBuffer()); + c.ok(buf.equals(Buffer.from(PDF)), "Inhalt aus dem Objektspeicher byte-identisch"); + } else { + console.log("↷ Byte-Roundtrip übersprungen (kein S3 konfiguriert, Stub-Adapter)"); + } + + await c.expectServiceError(() => updateDocumentMeta(ctxT1, teamDoc.id, { title: "x" }), "forbidden", "Monteur darf Metadaten nicht ändern"); + await c.expectServiceError(() => updateDocumentMeta(ctxLead, teamDoc.id, { visibility: "backoffice_only" }), "forbidden", "Teamleiter ohne document:write → forbidden"); + const meta = await updateDocumentMeta(ctxA, teamDoc.id, { title: "Montageanleitung", visibility: "team_lead" }); + c.ok(meta.visibility === "team_lead", "Backoffice ändert Sichtbarkeit"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, teamDoc.id), "not_found", "nach Umstellung auf team_lead für Monteur verborgen"); + await c.expectServiceError(() => deleteDocument(ctxB, planV2.id), "not_found", "Mandant B löscht Dokument von A → not_found"); + await deleteDocument(ctxA, planV2.id); + await c.expectServiceError(() => authorizeDocumentAccess(ctxA, planV2.id), "not_found", "soft-gelöschtes Dokument nicht mehr abrufbar"); + c.ok(!!(await prisma.document.findUnique({ where: { id: planV2.id } })), "Datensatz bleibt physisch erhalten (Soft Delete)"); +} + +/** Minimal JPEG header bytes. */ +function JUMP(): number[] { + return [...JPEG_HEAD, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]; +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-duplicates.ts b/scripts/test-stammdaten-duplicates.ts new file mode 100644 index 0000000..352fcde --- /dev/null +++ b/scripts/test-stammdaten-duplicates.ts @@ -0,0 +1,117 @@ +// L1 Stammdaten — Dublettenprüfung (Spec §7.3, US-003): +// (1) Normalisierung: Kleinschreibung, Umlaute, Rechtsformen, Straße/Str., Telefon nur Ziffern +// (2) Scoring: Kundennummer, Name, Adresse, E-Mail, Telefon; Schwelle +// (3) findDuplicateCustomers gegen die DB inkl. Mandantentrennung und Monteur-Scope +// +// Lauf: npx tsx scripts/test-stammdaten-duplicates.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { + DUPLICATE_THRESHOLD, + nameSimilarity, + normalizeCompanyName, + normalizePhone, + normalizeStreet, + normalizeText, + scoreDuplicate, +} from "../src/lib/customers/duplicates"; +import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; +import { checker, cleanupTenants, createTenant, createUser, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-dup-a"; +const SLUG_B = "zz-l1-dup-b"; +const DOMAIN = "zz-l1-dup.test"; +const c = checker("Dublettenprüfung"); + +async function main() { + console.log("— (1) Normalisierung —"); + c.ok(normalizeText(" ÄÖÜ ßtraße ") === "aeoeue sstrasse", "Umlaute/ß transliteriert, getrimmt, klein"); + c.ok(normalizeCompanyName("Müller Haustechnik GmbH & Co. KG") === "mueller haustechnik", "Rechtsform „GmbH & Co. KG“ entfernt"); + c.ok(normalizeCompanyName("MUELLER Haustechnik AG") === "mueller haustechnik", "Rechtsform „AG“ entfernt, Groß/Klein egal"); + c.ok(normalizeCompanyName("Bauer e.K.") === "bauer", "Rechtsform „e.K.“ entfernt"); + c.ok(normalizeCompanyName("AGRAR Service") === "agrar service", "„AG“ innerhalb eines Wortes bleibt erhalten"); + c.ok(normalizeStreet("Hafenstraße") === "hafenstr" && normalizeStreet("Hafen-Str.") === "hafenstr" && normalizeStreet("Hafen Strasse") === "hafenstr", "Straße/Str./Strasse vereinheitlicht"); + c.ok(normalizePhone("+49 40 123456-0") === "0401234560" && normalizePhone("040 / 123 456 0") === "0401234560", "Telefon nur Ziffern, +49 → 0"); + c.ok(normalizePhone("12") === "", "zu kurze Nummern werden ignoriert"); + + console.log("\n— (2) Scoring —"); + const existing = { + customerNumber: "K-00042", + companyName: "Müller Haustechnik GmbH", + street: "Hafenstraße", + houseNumber: "12", + postalCode: "20457", + email: "info@mueller.example", + phone: "+49 40 123456-0", + }; + const byNumber = scoreDuplicate({ customerNumber: "k-00042" }, existing); + c.ok(byNumber.score === 1 && byNumber.reasons.includes("customer_number"), "gleiche Kundennummer → Score 1"); + const byNameAddress = scoreDuplicate({ companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }, existing); + c.ok(Math.abs(byNameAddress.score - 0.76) < 0.001 && byNameAddress.reasons.join() === "company_name,address", `Name + Adresse → 0.76 (${byNameAddress.score})`); + const byEmail = scoreDuplicate({ email: "INFO@mueller.example " }, existing); + c.ok(byEmail.score === 0.6 && byEmail.reasons.join() === "email", "E-Mail (Groß/Klein, Leerzeichen) → 0.6"); + const byPhone = scoreDuplicate({ mobile: "040 1234560" }, existing); + c.ok(byPhone.score === 0.5 && byPhone.reasons.join() === "phone", "Telefon gegen Mobil-Eingabe → 0.5"); + const similar = scoreDuplicate({ companyName: "Müller Haustechnick" }, existing); + c.ok(similar.reasons.includes("company_name") && similar.score === 0.45, `ähnlicher Name (Tippfehler) → 0.45 (${similar.score})`); + c.ok(nameSimilarity("abc", "xyz") === 0, "unähnliche Namen → Ähnlichkeit 0"); + const unrelated = scoreDuplicate({ companyName: "Nordlicht Elektro", postalCode: "10115", street: "Invalidenstraße" }, existing); + c.ok(unrelated.score === 0 && unrelated.score < DUPLICATE_THRESHOLD, "fremder Kunde → Score 0 (unter Schwelle)"); + const all = scoreDuplicate({ ...existing }, existing); + c.ok(all.score === 1 && all.reasons.length === 5, "alle Merkmale gleich → 5 Gründe, Score 1"); + + console.log("\n— (3) findDuplicateCustomers (DB) —"); + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Dubletten A"); + const tB = await createTenant(SLUG_B, "L1 Dubletten B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + + const custA = await prisma.customer.create({ data: { tenantId: tA.id, ...existing } }); + const otherA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00043", companyName: "Schmidt Bedachung", city: "Kiel", postalCode: "24103" } }); + const mergedA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00044", companyName: "Müller Haustechnik", status: "merged" } }); + const custB = await prisma.customer.create({ data: { tenantId: tB.id, ...existing } }); + + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const hits = await findDuplicateCustomers(ctxA, { companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }); + c.ok(hits.length === 1 && hits[0].customerId === custA.id, "Treffer in Mandant A gefunden (Name + Adresse)"); + c.ok(hits[0]?.score >= 0.76 && hits[0]?.displayName === existing.companyName, "Treffer mit Score und Anzeigename"); + c.ok(!hits.some((h) => h.customerId === custB.id), "Mandantentrennung: gleicher Kunde in Mandant B wird NICHT gefunden"); + c.ok(!hits.some((h) => h.customerId === mergedA.id), "zusammengeführte Kunden werden nicht vorgeschlagen"); + c.ok(!hits.some((h) => h.customerId === otherA.id), "unähnlicher Kunde nicht vorgeschlagen"); + + const phoneHits = await findDuplicateCustomers(ctxA, { phone: "040 1234560" }); + c.ok(phoneHits.length === 1 && phoneHits[0].customerId === custA.id && phoneHits[0].reasons.join() === "phone", "Treffer nur über formatiert gespeicherte Telefonnummer"); + + const numberHits = await findDuplicateCustomers(ctxA, { customerNumber: "k-00042" }); + c.ok(numberHits[0]?.customerId === custA.id && numberHits[0]?.score === 1, "Treffer über Kundennummer (Score 1)"); + + const excluded = await findDuplicateCustomers(ctxA, { customerNumber: "K-00042" }, { excludeId: custA.id }); + c.ok(excluded.length === 0, "excludeId schließt den eigenen Datensatz aus"); + + const hitsB = await findDuplicateCustomers(ctxB, { customerNumber: "K-00043" }); + c.ok(hitsB.length === 0, "Mandant B findet Kunden von A nicht über deren Kundennummer"); + + const techHits = await findDuplicateCustomers(ctxFor(tA.id, techA.id, "technician"), { customerNumber: "K-00042" }); + c.ok(techHits.length === 0, "Monteur ohne sichtbaren Auftrag erhält keine Kundentreffer (Scope)"); + + c.ok((await findDuplicateCustomers(ctxA, {})).length === 0, "leerer Kandidat → keine Treffer"); + + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); +} + +main() + .catch(async (err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch(() => {}); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-sites.ts b/scripts/test-stammdaten-sites.ts new file mode 100644 index 0000000..f78cde6 --- /dev/null +++ b/scripts/test-stammdaten-sites.ts @@ -0,0 +1,173 @@ +// L1 Stammdaten — Objekte und Objekt-Historie (Spec §8, US-005, US-011): +// (1) Objekt anlegen/ändern inkl. Validierung Kunde/Kontakt, Kartenlink, Soft Delete +// (2) Historie: chronologisch, Arbeiten, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten +// (3) Rollen/Scope: Monteur sieht nur freigegebene Einsätze; ohne sichtbaren Auftrag → not_found +// (4) Mandantentrennung +// +// Lauf: npx tsx scripts/test-stammdaten-sites.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { getSiteHistory } from "../src/server/services/sites/history"; +import { siteMapUrl } from "../src/server/services/sites/map-link"; +import { createSite, deleteSite, getSite, listSites, updateSite } from "../src/server/services/sites/sites"; +import { + checker, + cleanupTenants, + createTeamWithMember, + createTenant, + createUser, + createWorkOrder, + ctxFor, + disconnect, + prisma, +} from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-site-a"; +const SLUG_B = "zz-l1-site-b"; +const DOMAIN = "zz-l1-site.test"; +const c = checker("Objekte & Historie"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Objekte A"); + const tB = await createTenant(SLUG_B, "L1 Objekte B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); + const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); + const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Objektkunde" } }); + const otherCustomer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-2", companyName: "Anderer Kunde" } }); + const otherContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: otherCustomer.id, name: "Fremdkontakt" } }); + const ownContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Hausmeister" } }); + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-1", companyName: "B-Kunde" } }); + + console.log("— (1) Objekte —"); + const site = await createSite(ctxA, { + customerId: customer.id, + name: "Wohnanlage Süd", + street: "Hafenstraße", + houseNumber: "12", + postalCode: "20457", + city: "Hamburg", + contactId: ownContact.id, + accessNotes: "Schlüssel beim Hausmeister", + parkingNotes: "Hof", + safetyNotes: "Asbest im Keller", + latitude: "53,5413" as never, + longitude: 9.9841, + }); + c.ok(site.customerId === customer.id && site.latitude === 53.5413 && site.safetyNotes === "Asbest im Keller", "Objekt mit Hinweisen und Koordinaten (Komma-Dezimal) angelegt"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "site", entityId: site.id, action: "create" } })), "Audit create"); + await c.expectServiceError(() => createSite(ctxA, { customerId: customer.id, name: "X", contactId: otherContact.id }), "invalid", "Kontakt eines anderen Kunden → invalid", "contact_mismatch"); + await c.expectServiceError(() => createSite(ctxA, { customerId: customerB.id, name: "X" }), "invalid", "Kunde aus fremdem Mandanten → invalid", "customer_not_found"); + await c.expectServiceError(() => createSite(ctxT1, { customerId: customer.id, name: "X" }), "forbidden", "Monteur darf keine Objekte anlegen"); + await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "" }), "ZodError", "ohne Bezeichnung → Validierungsfehler"); + await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "X", latitude: 123 }), "ZodError", "Breitengrad außerhalb −90..90 → Validierungsfehler"); + + const moved = await updateSite(ctxA, site.id, { technicalNotes: "Heizung Baujahr 2004" }); + c.ok(moved.technicalNotes === "Heizung Baujahr 2004" && moved.contactId === ownContact.id, "Objekt geändert, Kontakt bleibt"); + const url = siteMapUrl(site); + c.ok(!!url && url.startsWith("https://www.openstreetmap.org/?mlat=53.541300"), `Kartenlink aus Koordinaten (${url})`); + const addrUrl = siteMapUrl({ street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }); + c.ok(addrUrl === `https://www.openstreetmap.org/search?query=${encodeURIComponent("Hafenstraße 12, 20457 Hamburg")}`, "Kartenlink aus Adresse (kein Embed)"); + c.ok(siteMapUrl({ street: "Nur Straße" }) === null, "ohne Ort/PLZ kein Kartenlink"); + + console.log("\n— (2) Historie (Backoffice) —"); + const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id); + const teamY = await createTeamWithMember(tA.id, "Team Y", null); + + // WO1: freigegeben, Team Y (nicht Team des Monteurs), mit allen Nachweisen + const wo1 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "released_for_billing", title: "Wartung Heizung", followUpWork: "Nachkontrolle im Herbst" }); + const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo1.id, userId: boA.id, startedAt: new Date("2026-03-01T08:00:00Z"), status: "ended" } }); + await prisma.activityNote.createMany({ + data: [ + { tenantId: tA.id, workOrderId: wo1.id, kind: "work_done", text: "Heizung gewartet" }, + { tenantId: tA.id, workOrderId: wo1.id, kind: "follow_up", text: "Ventil tauschen" }, + { tenantId: tA.id, workOrderId: wo1.id, kind: "general", text: "INTERN nicht anzeigen" }, + ], + }); + await prisma.materialUsage.createMany({ + data: [ + { tenantId: tA.id, workOrderId: wo1.id, workSessionId: session.id, name: "Ventil", unit: "Stk", actualQuantity: 1, usageStatus: "fully_used" }, + { tenantId: tA.id, workOrderId: wo1.id, name: "ventil ", unit: "Stk", actualQuantity: 1.5, usageStatus: "additional" }, + { tenantId: tA.id, workOrderId: wo1.id, name: "Dichtung", unit: "Stk", actualQuantity: 4, usageStatus: "not_used" }, + ], + }); + for (let i = 0; i < 2; i++) { + const doc = await prisma.document.create({ data: { tenantId: tA.id, workOrderId: wo1.id, category: "photo", fileName: `p${i}.jpg`, storageKey: `${tA.id}/p${i}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0", lineageId: `lin-p${i}-${wo1.id}` } }); + await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo1.id, documentId: doc.id, takenAt: new Date() } }); + } + const report1 = await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo1.id, type: "completion", reportDate: new Date("2026-03-01"), lineageId: `r-${wo1.id}`, status: "approved", content: {} } }); + await prisma.signature.create({ data: { tenantId: tA.id, reportId: report1.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } }); + + // WO2: Team X (Monteur 1), in Arbeit, nicht freigegeben + const wo2 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id, status: "in_progress", title: "Leitung verlegen", plannedStart: new Date("2026-04-10T07:00:00Z") }); + await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo2.id, kind: "work_done", text: "Leitung verlegt" } }); + // WO3: Team Y, Bericht nur eingereicht + const wo3 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "in_review", plannedStart: new Date("2026-02-01T07:00:00Z") }); + await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo3.id, type: "daily", reportDate: new Date("2026-02-01"), lineageId: `r-${wo3.id}`, status: "submitted", content: {} } }); + // WO an anderem Objekt darf nicht erscheinen + const otherSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Anderes Objekt" } }); + await createWorkOrder(tA.id, { customerId: customer.id, siteId: otherSite.id, assignedTeamId: teamY.id }); + + const all = await getSiteHistory(ctxA, site.id, { onlyApproved: false }); + c.ok(all.total === 3 && !all.onlyApproved, `Backoffice sieht alle 3 Einsätze am Objekt (${all.total})`); + c.ok(all.items.map((e) => e.workOrderId).join() === [wo2.id, wo1.id, wo3.id].join(), "chronologisch, neueste zuerst (Einsatzbeginn/Termin)"); + const e1 = all.items.find((e) => e.workOrderId === wo1.id)!; + c.ok(e1.workDone.join() === "Heizung gewartet" && !e1.summary.includes("INTERN"), "durchgeführte Arbeiten aus work_done, interne Notizen nicht enthalten"); + c.ok(e1.materials.length === 1 && e1.materials[0].quantity === 2.5 && e1.materials[0].unit === "Stk", `Material aggregiert, not_used ausgeschlossen (${JSON.stringify(e1.materials)})`); + c.ok(e1.photoCount === 2 && e1.signed && e1.approvedReports.length === 1 && e1.approvedReports[0].id === report1.id, "Fotoanzahl, Unterschrift, freigegebener Bericht"); + c.ok(e1.hasOpenFollowUp && e1.followUps.length === 2, "offene Folgearbeiten (followUpWork + follow_up-Notiz) markiert"); + c.ok(e1.team === "Team Y" && e1.date.toISOString() === "2026-03-01T08:00:00.000Z", "Team und Datum (Einsatzbeginn)"); + const e3 = all.items.find((e) => e.workOrderId === wo3.id)!; + c.ok(e3.approvedReports.length === 0 && !e3.hasOpenFollowUp, "eingereichter Bericht zählt nicht als freigegeben"); + const approvedOnly = await getSiteHistory(ctxA, site.id, { onlyApproved: true }); + c.ok(approvedOnly.total === 1 && approvedOnly.items[0].workOrderId === wo1.id, "onlyApproved=true → nur freigegebener Einsatz"); + const paged = await getSiteHistory(ctxA, site.id, { page: 2, pageSize: 2 }); + c.ok(paged.total === 3 && paged.items.length === 1, "Paginierung der Historie"); + + console.log("\n— (3) Rollen/Scope —"); + const t1 = await getSiteHistory(ctxT1, site.id, { onlyApproved: false }); + c.ok(t1.onlyApproved && t1.total === 1 && t1.items[0].workOrderId === wo1.id, "Monteur (Team X) erhält nur freigegebene Einsätze, auch mit onlyApproved=false"); + c.ok(!t1.items.some((e) => e.workOrderId === wo2.id || e.workOrderId === wo3.id), "nicht freigegebene Einsätze bleiben für Monteur verborgen"); + await c.expectServiceError(() => getSiteHistory(ctxT2, site.id), "not_found", "Monteur ohne sichtbaren Auftrag am Objekt → not_found"); + await c.expectServiceError(() => getSite(ctxT2, site.id), "not_found", "Monteur ohne Zuweisung liest Objekt → not_found"); + c.ok((await getSite(ctxT1, site.id)).id === site.id, "Monteur mit Teamauftrag liest Objekt"); + const t1List = await listSites(ctxT1, { pageSize: 50 }); + c.ok(t1List.items.map((s) => s.id).join() === site.id, "Monteur-Objektliste nur mit erreichbaren Objekten"); + await c.expectServiceError(() => updateSite(ctxT1, site.id, { name: "x" }), "forbidden", "Monteur darf Objekt nicht ändern"); + await c.expectServiceError(() => getSiteHistory(ctxFor(tA.id, tech1.id, "technician", { remove: ["site:read"] }), site.id), "forbidden", "ohne site:read → forbidden"); + + console.log("\n— (4) Mandantentrennung —"); + await c.expectServiceError(() => getSite(ctxB, site.id), "not_found", "Mandant B liest Objekt von A → not_found"); + await c.expectServiceError(() => getSiteHistory(ctxB, site.id), "not_found", "Mandant B liest Historie von A → not_found"); + await c.expectServiceError(() => updateSite(ctxB, site.id, { name: "gehackt" }), "not_found", "Mandant B ändert Objekt von A → not_found"); + await c.expectServiceError(() => deleteSite(ctxB, site.id), "not_found", "Mandant B löscht Objekt von A → not_found"); + c.ok((await listSites(ctxB)).total === 0, "Liste von B leer"); + c.ok((await prisma.site.findUnique({ where: { id: site.id } }))?.name === "Wohnanlage Süd", "Objekt A unverändert"); + + console.log("\n— Soft Delete —"); + await c.expectServiceError(() => deleteSite(ctxA, site.id), "blocked", "Löschen bei offenen Aufträgen → blocked", "open_work_orders"); + const emptySite = await createSite(ctxA, { customerId: customer.id, name: "Leeres Objekt" }); + const del = await deleteSite(ctxA, emptySite.id); + c.ok(!!del.deletedAt, "Objekt ohne Aufträge soft-gelöscht"); + await c.expectServiceError(() => getSite(ctxA, emptySite.id), "not_found", "gelöschtes Objekt → not_found"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-teams.ts b/scripts/test-stammdaten-teams.ts new file mode 100644 index 0000000..b4f1777 --- /dev/null +++ b/scripts/test-stammdaten-teams.ts @@ -0,0 +1,95 @@ +// L1 Stammdaten — Teams (Spec §11.1): +// (1) Anlage mit Teamleiter/Mitgliedern (gültig ab/bis), Validierung, Audit +// (2) Mitgliedschaft wirkt auf die Auftragssichtbarkeit (activeTeamIds) +// (3) Rollen: Monteur liest, verwaltet aber nicht; Mandantentrennung +// +// Lauf: npx tsx scripts/test-stammdaten-teams.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { createTeam, deleteTeam, getTeam, listTeams, updateTeam } from "../src/server/services/teams/teams"; +import { activeTeamIds } from "../src/server/services/work-orders/visibility"; +import { checker, cleanupTenants, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-team-a"; +const SLUG_B = "zz-l1-team-b"; +const DOMAIN = "zz-l1-team.test"; +const c = checker("Teams"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Teams A"); + const tB = await createTenant(SLUG_B, "L1 Teams B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Tina Teamleiter"); + const tech = await createUser(tA.id, `tech@${DOMAIN}`, "Max Monteur"); + const inactive = await createUser(tA.id, `inactive@${DOMAIN}`, "Ina Inaktiv"); + await prisma.user.update({ where: { id: inactive.id }, data: { status: "DEACTIVATED" } }); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const foreign = await createUser(tB.id, `foreign@${DOMAIN}`, "Fremd B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxTech = ctxFor(tA.id, tech.id, "technician"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + console.log("— (1) Anlage —"); + const team = await createTeam(ctxA, { + name: "Team Nord", + leaderUserId: lead.id, + phone: "040 555", + vehicle: "HH-CV 101", + area: "Hamburg Nord", + notes: "Schlüssel im Fahrzeug", + members: [{ userId: tech.id, validFrom: "2026-01-01", validTo: "" }], + }); + c.ok(team.leader?.id === lead.id && team.members.length === 1 && team.vehicle === "HH-CV 101", "Team mit Teamleiter, Mitglied, Fahrzeug angelegt"); + c.ok(team.members[0].validTo === null && team.members[0].validFrom.toISOString().startsWith("2026-01-01"), "gültig ab/bis übernommen"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "team", entityId: team.id, action: "create" } })), "Audit create"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Nord", members: [] }), "conflict", "doppelter Teamname → conflict", "name_taken"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Fremd", members: [{ userId: foreign.id }] }), "invalid", "Mitglied aus fremdem Mandanten → invalid", "inactive_user"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Inaktiv", leaderUserId: inactive.id, members: [] }), "invalid", "deaktivierter Nutzer als Teamleiter → invalid", "inactive_user"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Doppelt", members: [{ userId: tech.id }, { userId: tech.id }] }), "invalid", "Person doppelt → invalid", "duplicate_member"); + await c.expectErrorName(() => createTeam(ctxA, { name: "Team Zeit", members: [{ userId: tech.id, validFrom: "2026-05-01", validTo: "2026-04-01" }] }), "ZodError", "gültig bis vor gültig ab → Validierungsfehler"); + + console.log("\n— (2) Mitgliedschaft & Sichtbarkeit —"); + c.ok((await activeTeamIds(ctxTech)).includes(team.id), "aktives Mitglied → Team zählt für Auftragssichtbarkeit"); + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Kunde" } }); + const order = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: team.id }); + const ended = await updateTeam(ctxA, team.id, { + name: "Team Nord", + leaderUserId: lead.id, + members: [{ userId: tech.id, validFrom: "2025-01-01", validTo: "2025-12-31" }], + }); + c.ok(ended.members.length === 1 && !!ended.members[0].validTo, "Mitgliedschaft beendet (gültig bis gesetzt)"); + c.ok(!(await activeTeamIds(ctxTech)).includes(team.id), "abgelaufene Mitgliedschaft → Team zählt nicht mehr"); + const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "team", entityId: team.id, action: "update" } }); + c.ok(!!upAudit?.before && !!upAudit?.after, "Audit update mit before/after"); + + console.log("\n— (3) Rollen & Mandantentrennung —"); + c.ok((await listTeams(ctxTech)).some((x) => x.id === team.id), "Monteur darf Teams lesen (team:read)"); + await c.expectServiceError(() => createTeam(ctxTech, { name: "Monteurteam", members: [] }), "forbidden", "Monteur darf keine Teams anlegen"); + await c.expectServiceError(() => updateTeam(ctxTech, team.id, { name: "x", members: [] }), "forbidden", "Monteur darf Teams nicht ändern"); + await c.expectServiceError(() => getTeam(ctxB, team.id), "not_found", "Mandant B liest Team von A → not_found"); + await c.expectServiceError(() => updateTeam(ctxB, team.id, { name: "gehackt", members: [] }), "not_found", "Mandant B ändert Team von A → not_found"); + await c.expectServiceError(() => deleteTeam(ctxB, team.id), "not_found", "Mandant B löscht Team von A → not_found"); + c.ok((await listTeams(ctxB)).length === 0, "Teamliste von B leer"); + + console.log("\n— Soft Delete —"); + await c.expectServiceError(() => deleteTeam(ctxA, team.id), "blocked", "Löschen mit offenem Auftrag → blocked", "open_work_orders"); + await prisma.workOrder.update({ where: { id: order.id }, data: { status: "billed" } }); + const del = await deleteTeam(ctxA, team.id); + c.ok(!!del.deletedAt && del.status === "inactive", "Team soft-gelöscht und inaktiv"); + const again = await createTeam(ctxA, { name: "Team Nord", members: [] }); + c.ok(again.name === "Team Nord", "Name nach Löschen wieder verwendbar"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + });