Files
craftvia/scripts/test-stammdaten-sites.ts
T
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

174 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});