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:
2026-09-14 12:29:15 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 2311b35d8c
29 changed files with 2863 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
// Shared fixtures for scripts/test-auftraege-*.ts (lane L2). Not a test itself (no "test-" prefix).
//
// Creates isolated zz-test tenants with the four standard roles, a team with lead + technician,
// an outside technician, a customer and a site; builds ServiceCtx objects exactly like
// ctxFromGuard does (tenant db + DB-authoritative permission set).
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
import type { ServiceCtx } from "../src/server/services/context";
export let failures = 0;
export const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Expect a ServiceError with the given code (e.g. "not_found"). Returns the error. */
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string): Promise<unknown> {
try {
await fn();
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
return null;
} catch (e) {
const c = (e as { code?: string }).code;
ok(c === code, `${msg}${c === code ? "" : ` — erhalten ${c ?? (e as Error).message}`}`);
return e;
}
}
export type Fixture = {
tenantId: string;
users: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", { id: string }>;
ctx: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", ServiceCtx>;
teamId: string;
otherTeamId: string;
customerId: string;
siteId: string;
contactId: string;
};
const ROLE_OF = { admin: "tenant-admin", backoffice: "backoffice", lead: "team-lead", tech: "technician", outsider: "technician" } as const;
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
}
export async function cleanupTenants(slugs: string[]) {
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (!ids.length) return;
const w = { where: { tenantId: { in: ids } } };
await prisma.syncOperation.deleteMany(w);
await prisma.notification.deleteMany(w);
await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w);
await prisma.photo.deleteMany(w);
await prisma.activityNote.deleteMany(w);
await prisma.voiceNote.deleteMany(w);
await prisma.timeEntry.deleteMany(w);
await prisma.materialUsage.deleteMany(w);
await prisma.workSession.deleteMany(w);
await prisma.materialPlan.deleteMany(w);
await prisma.photoRequirement.deleteMany(w);
await prisma.checklistItem.deleteMany(w);
await prisma.workOrderStatusChange.deleteMany(w);
await prisma.workOrderAssignee.deleteMany(w);
await prisma.document.deleteMany(w);
await prisma.workOrder.deleteMany(w);
await prisma.teamMember.deleteMany(w);
await prisma.team.deleteMany(w);
await prisma.site.deleteMany(w);
await prisma.contact.deleteMany(w);
await prisma.customer.deleteMany(w);
await prisma.checklistTemplate.deleteMany(w);
await prisma.orderType.deleteMany(w);
await prisma.numberSequence.deleteMany(w);
await prisma.tenantSettings.deleteMany(w);
await prisma.auditLog.deleteMany(w);
await prisma.user.deleteMany(w);
await prisma.role.deleteMany(w);
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-auftraege.test" }, memberships: { none: {} } } });
}
async function ensurePermissionIds(keys: readonly string[]) {
const rows = await Promise.all(keys.map((key) => prisma.permission.upsert({ where: { key }, update: {}, create: { key } })));
return rows.map((r) => r.id);
}
export async function createFixture(slug: string): Promise<Fixture> {
const tenant = await prisma.tenant.create({ data: { name: `L2 Test ${slug}`, slug } });
await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: `L2 Test ${slug}`, timezone: "Europe/Berlin" } });
const roleIds: Partial<Record<RoleKey, string>> = {};
for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) {
const permIds = await ensurePermissionIds(ROLE_DEFS[key].permissions);
const role = await prisma.role.create({
data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: permIds.map((permissionId) => ({ permissionId })) } },
});
roleIds[key] = role.id;
}
const users = {} as Fixture["users"];
const ctx = {} as Fixture["ctx"];
for (const name of Object.keys(ROLE_OF) as (keyof typeof ROLE_OF)[]) {
const email = `${name}-${slug}@zz-auftraege.test`;
const identity = await prisma.identity.create({ data: { email, passwordHash: "x" } });
const user = await prisma.user.create({
data: { tenantId: tenant.id, identityId: identity.id, email, name: `${name} ${slug}`, userRoles: { create: [{ roleId: roleIds[ROLE_OF[name]]! }] } },
});
users[name] = { id: user.id };
ctx[name] = ctxFor(tenant.id, user.id, ROLE_OF[name]);
}
const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } });
await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team.id, userId: users.tech.id, validFrom: new Date(Date.now() - 86400000) } });
const otherTeam = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd" } });
await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: otherTeam.id, userId: users.outsider.id, validFrom: new Date(Date.now() - 86400000) } });
const customer = await prisma.customer.create({ data: { tenantId: tenant.id, companyName: `Kunde ${slug} GmbH`, customerNumber: `K-${slug}`, city: "Hamburg", street: "Hafenweg" } });
const site = await prisma.site.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Objekt ${slug}`, city: "Hamburg", street: "Kaistraße", houseNumber: "7" } });
const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Frau Kontakt ${slug}` } });
return { tenantId: tenant.id, users, ctx, teamId: team.id, otherTeamId: otherTeam.id, customerId: customer.id, siteId: site.id, contactId: contact.id };
}
/** Raw work order in a given status, assigned to the fixture team (setup only — bypasses the state machine). */
export async function rawWorkOrder(f: Fixture, status: string, extra: Record<string, unknown> = {}) {
const n = Math.random().toString(36).slice(2, 10);
return prisma.workOrder.create({
data: {
tenantId: f.tenantId,
number: `T-${n}`,
title: `Testauftrag ${n}`,
customerId: f.customerId,
siteId: f.siteId,
status: status as never,
assignedTeamId: f.teamId,
teamLeadUserId: f.users.lead.id,
signatureRequired: true,
...extra,
},
});
}
export async function approvedCompletionReport(f: Fixture, workOrderId: string, opts: { status?: string; signature?: boolean } = {}) {
const report = await prisma.report.create({
data: {
tenantId: f.tenantId,
workOrderId,
type: "completion",
reportDate: new Date(),
lineageId: `lin-${workOrderId}-${Math.random().toString(36).slice(2, 8)}`,
status: (opts.status ?? "approved") as never,
content: {},
},
});
if (opts.signature) {
await prisma.signature.create({ data: { tenantId: f.tenantId, reportId: report.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } });
}
return report;
}
export function finish(cleanup: () => Promise<void>) {
return async () => {
await cleanup();
await prisma.$disconnect();
if (failures === 0) console.log("\nOK — alle Nachweise erfüllt.");
else console.log(`\n${failures} FEHLER.`);
process.exit(failures === 0 ? 0 : 1);
};
}