Files
craftvia/scripts/test-stammdaten-customers.ts
msolarczekandClaude Opus 5 6423351035 L1 Stammdaten: Tests für Dubletten, Kunden, Objekte, Teams und Dokumente
Kernlogik, Mandantentrennung (Mandant B liest/ändert nichts von A) und
Rollen/Scope (Monteur ohne Zuweisung → not_found/forbidden).

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

201 lines
14 KiB
TypeScript

// 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);
});