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>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// 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])));
|
||||
Reference in New Issue
Block a user