Files
craftvia/scripts/test-auftraege-scope.ts
T
msolarczekandClaude Opus 5 2311b35d8c L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests
Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards,
Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche,
Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise).
Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität.

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

109 lines
8.1 KiB
TypeScript

// L2 Aufträge: Sichtbarkeit (Rollen/Scope) und Mandantentrennung.
//
// (1) Monteur eines fremden Teams: Liste leer, Detail/Übergang/Blocker → not_found
// (2) Team-Mitglied, Teamleiter und einzeln zugewiesener Monteur sehen den Auftrag
// (3) Monteur ohne Rechte: Anlage/Änderung/Zuweisung/Material → forbidden
// (4) Mandant B kann Aufträge von A weder lesen (Liste, Detail, Suche, Dashboard) noch ändern
// (Update, Übergang, Zuweisung, Material, Konflikt) — auch nicht über den Tenant-Guard direkt
// (5) Suche respektiert Scope (Monteur fremdes Team findet nichts) und Mandant
//
// Lauf: npx tsx scripts/test-auftraege-scope.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { parseListParams } from "../src/lib/work-orders/filters";
import { createWorkOrder } from "../src/server/services/work-orders/create";
import { updateWorkOrder } from "../src/server/services/work-orders/update";
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 { addMaterialPlan } from "../src/server/services/work-orders/materials";
import { listWorkOrders } from "../src/server/services/work-orders/list";
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
import { getDashboardTiles } from "../src/server/services/work-orders/dashboard";
import { searchAll } from "../src/server/services/work-orders/search";
import { discardSyncConflict } from "../src/server/services/work-orders/conflicts";
import { cleanupTenants, createFixture, expectCode, finish, ok } from "./lib-auftraege-fixtures";
const SLUG_A = "zz-l2-scope-a";
const SLUG_B = "zz-l2-scope-b";
async function main() {
await cleanupTenants([SLUG_A, SLUG_B]);
const A = await createFixture(SLUG_A);
const B = await createFixture(SLUG_B);
const wo = await createWorkOrder(A.ctx.backoffice, { title: "Wärmepumpe Zauberwort", customerId: A.customerId, siteId: A.siteId });
await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId });
await prisma.activityNote.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, text: "Zauberwort im Keller", authorId: A.users.tech.id } });
const solo = await createWorkOrder(A.ctx.backoffice, { title: "Einzelzuweisung", customerId: A.customerId });
await assignWorkOrder(A.ctx.backoffice, { workOrderId: solo.id, teamId: A.otherTeamId, userIds: [A.users.tech.id] });
console.log("\n— (1) Monteur fremdes Team —");
const outsiderList = await listWorkOrders(A.ctx.outsider, parseListParams({}));
ok(outsiderList.items.every((i) => i.id !== wo.id), "Liste des fremden Monteurs enthält den Auftrag nicht");
await expectCode(() => getWorkOrderDetail(A.ctx.outsider, wo.id), "not_found", "Detail → not_found");
await expectCode(() => transitionWorkOrder(A.ctx.outsider, { workOrderId: wo.id, to: "accepted" }), "not_found", "Übergang → not_found (Existenz nicht preisgegeben)");
await expectCode(() => getCompletionBlockers(A.ctx.outsider, wo.id), "not_found", "Blocker → not_found");
console.log("\n— (2) Berechtigte Feldrollen —");
const techList = await listWorkOrders(A.ctx.tech, parseListParams({}));
ok(techList.items.some((i) => i.id === wo.id), "Team-Mitglied sieht den Team-Auftrag");
ok(techList.items.some((i) => i.id === solo.id), "Einzeln zugewiesener Monteur sieht Auftrag eines anderen Teams");
ok((await getWorkOrderDetail(A.ctx.lead, wo.id)).id === wo.id, "Teamleiter sieht den Auftrag");
const accepted = await transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "accepted" });
ok(accepted.status === "accepted", "Team-Mitglied darf annehmen");
const outsiderSolo = await listWorkOrders(A.ctx.outsider, parseListParams({}));
ok(outsiderSolo.items.some((i) => i.id === solo.id), "Mitglied des zugewiesenen Teams sieht den Einzelzuweisungs-Auftrag");
console.log("\n— (3) Monteur ohne Backoffice-Rechte —");
await expectCode(() => createWorkOrder(A.ctx.tech, { title: "X", customerId: A.customerId }), "forbidden", "Anlage → forbidden");
await expectCode(() => updateWorkOrder(A.ctx.tech, wo.id, { title: "X" }), "forbidden", "Änderung → forbidden");
await expectCode(() => assignWorkOrder(A.ctx.tech, { workOrderId: wo.id, teamId: A.teamId }), "forbidden", "Zuweisung → forbidden");
await expectCode(() => addMaterialPlan(A.ctx.tech, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "forbidden", "Materialvorgabe → forbidden");
await expectCode(() => transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "forbidden", "Storno → forbidden");
console.log("\n— (4) Mandantentrennung —");
const bList = await listWorkOrders(B.ctx.admin, parseListParams({}));
ok(bList.total === 0, "Mandant B: Liste leer");
await expectCode(() => getWorkOrderDetail(B.ctx.admin, wo.id), "not_found", "Mandant B: Detail → not_found");
await expectCode(() => updateWorkOrder(B.ctx.admin, wo.id, { title: "gekapert" }), "not_found", "Mandant B: Update → not_found");
await expectCode(() => transitionWorkOrder(B.ctx.admin, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "not_found", "Mandant B: Übergang → not_found");
await expectCode(() => assignWorkOrder(B.ctx.admin, { workOrderId: wo.id, teamId: B.teamId }), "not_found", "Mandant B: Zuweisung → not_found");
await expectCode(() => addMaterialPlan(B.ctx.admin, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "not_found", "Mandant B: Materialvorgabe → not_found");
await expectCode(() => createWorkOrder(B.ctx.admin, { title: "X", customerId: A.customerId }), "invalid", "Mandant B: Anlage mit Kunde von A → invalid");
const tilesB = await getDashboardTiles(B.ctx.admin, {});
ok(Object.values(tilesB).every((v) => v === 0), "Mandant B: alle Dashboard-Kacheln 0");
const direct = await B.ctx.admin.db.workOrder.findUnique({ where: { id: wo.id } });
ok(direct === null, "Tenant-Guard: findUnique über Mandant B → null");
const updMany = await B.ctx.admin.db.workOrder.updateMany({ where: { id: wo.id }, data: { title: "gekapert" } });
ok(updMany.count === 0, "Tenant-Guard: updateMany über Mandant B ändert 0 Zeilen");
const opA = await prisma.syncOperation.create({ data: { tenantId: A.tenantId, userId: A.users.tech.id, clientOpId: crypto.randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } });
await expectCode(() => discardSyncConflict(B.ctx.admin, opA.id), "not_found", "Mandant B: Konflikt von A → not_found");
const title = (await prisma.workOrder.findUnique({ where: { id: wo.id } }))?.title;
ok(title === "Wärmepumpe Zauberwort", "Auftrag von A unverändert");
console.log("\n— (5) Suche —");
const sA = await searchAll(A.ctx.backoffice, { q: "zauberwort" });
ok(sA.workOrders.some((w) => w.id === wo.id) && sA.notes.length === 1, "Backoffice A findet Auftrag + Notiz");
const sCust = await searchAll(A.ctx.backoffice, { q: "Kaistraße" });
ok(sCust.sites.length === 1 && sCust.workOrders.some((w) => w.id === wo.id), "Adresssuche findet Objekt und Auftrag");
const sB = await searchAll(B.ctx.admin, { q: "zauberwort" });
ok(sB.workOrders.length === 0 && sB.notes.length === 0, "Mandant B findet nichts von A");
const sB2 = await searchAll(B.ctx.admin, { q: SLUG_A });
ok(sB2.customers.length === 0 && sB2.sites.length === 0 && sB2.contacts.length === 0, "Mandant B findet keine Kunden/Objekte/Kontakte von A");
const sOut = await searchAll(A.ctx.outsider, { q: "zauberwort" });
ok(sOut.workOrders.length === 0 && sOut.notes.length === 0, "Monteur fremdes Team findet Auftrag/Notiz nicht");
const sTech = await searchAll(A.ctx.tech, { q: "zauberwort", statuses: ["accepted"] });
ok(sTech.workOrders.length === 1, "Team-Mitglied findet Auftrag (mit Statusfilter)");
const sFiltered = await searchAll(A.ctx.backoffice, { q: "zauberwort", statuses: ["billed"] });
ok(sFiltered.workOrders.length === 0, "Statusfilter der Suche greift");
}
main()
.catch((e) => {
console.error(e);
ok(false, "unerwarteter Fehler");
})
.finally(finish(() => cleanupTenants([SLUG_A, SLUG_B])));