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