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>
This commit is contained in:
2026-09-14 12:26:27 +02:00
co-authored by Claude Opus 5
parent d18f4fe431
commit 6423351035
6 changed files with 936 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
// 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);
});