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:
@@ -0,0 +1,140 @@
|
|||||||
|
// Shared fixtures for the scripts/test-stammdaten-*.ts tests (lane L1 Stammdaten).
|
||||||
|
// Creates isolated zz-test tenants with the raw owner client and builds ServiceCtx objects with
|
||||||
|
// the permission sets of the standard roles (src/server/rbac.ts ROLE_DEFS). Not a test itself
|
||||||
|
// (the runner only picks up files named test-*.ts).
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { prisma, dbForTenant } from "../src/server/db";
|
||||||
|
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||||
|
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||||
|
|
||||||
|
export function checker(title: string) {
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: unknown, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
/** Expect `fn` to reject with a ServiceError of `code` (and optionally `reason`). */
|
||||||
|
const expectServiceError = async (fn: () => Promise<unknown>, code: ServiceError["code"], msg: string, reason?: string) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
ok(false, `${msg} — kein Fehler geworfen`);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as ServiceError;
|
||||||
|
const actualReason = (e.details as { reason?: string } | undefined)?.reason;
|
||||||
|
const match = e instanceof ServiceError && e.code === code && (!reason || actualReason === reason);
|
||||||
|
ok(match, `${msg}${match ? "" : ` — erhalten: ${e?.name}/${e?.code ?? ""}/${actualReason ?? ""} ${e?.message ?? ""}`}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/** Expect `fn` to reject with any error whose name matches (e.g. ZodError). */
|
||||||
|
const expectErrorName = async (fn: () => Promise<unknown>, name: string, msg: string) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
ok(false, `${msg} — kein Fehler geworfen`);
|
||||||
|
} catch (err) {
|
||||||
|
ok((err as Error)?.name === name, `${msg}${(err as Error)?.name === name ? "" : ` — erhalten: ${(err as Error)?.name} ${(err as Error)?.message}`}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const finish = () => {
|
||||||
|
console.log(failures === 0 ? `\nOK — ${title}: alle Prüfungen erfüllt.` : `\n${failures} FEHLER in ${title}.`);
|
||||||
|
return failures;
|
||||||
|
};
|
||||||
|
return { ok, expectServiceError, expectErrorName, finish, get failures() { return failures; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODELS_IN_DELETE_ORDER = [
|
||||||
|
"signature",
|
||||||
|
"report",
|
||||||
|
"photo",
|
||||||
|
"activityNote",
|
||||||
|
"voiceNote",
|
||||||
|
"materialUsage",
|
||||||
|
"timeEntry",
|
||||||
|
"workSession",
|
||||||
|
"materialPlan",
|
||||||
|
"checklistItem",
|
||||||
|
"photoRequirement",
|
||||||
|
"workOrderStatusChange",
|
||||||
|
"workOrderAssignee",
|
||||||
|
"document",
|
||||||
|
"syncOperation",
|
||||||
|
"notification",
|
||||||
|
"aiGeneration",
|
||||||
|
"workOrder",
|
||||||
|
"importJob",
|
||||||
|
"site",
|
||||||
|
"contact",
|
||||||
|
"customer",
|
||||||
|
"teamMember",
|
||||||
|
"team",
|
||||||
|
"checklistTemplate",
|
||||||
|
"orderType",
|
||||||
|
"numberSequence",
|
||||||
|
"auditLog",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function cleanupTenants(slugs: string[], emailDomain: string) {
|
||||||
|
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
|
||||||
|
const ids = tenants.map((t) => t.id);
|
||||||
|
if (ids.length) {
|
||||||
|
const client = prisma as unknown as Record<string, { deleteMany: (a: unknown) => Promise<unknown> }>;
|
||||||
|
for (const model of MODELS_IN_DELETE_ORDER) await client[model].deleteMany({ where: { tenantId: { in: ids } } });
|
||||||
|
await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } });
|
||||||
|
await prisma.user.deleteMany({ where: { tenantId: { in: ids } } });
|
||||||
|
await prisma.tenantModule.deleteMany({ where: { tenantId: { in: ids } } });
|
||||||
|
await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: ids } } });
|
||||||
|
await prisma.role.deleteMany({ where: { tenantId: { in: ids } } });
|
||||||
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
await prisma.identity.deleteMany({ where: { email: { endsWith: `@${emailDomain}` }, memberships: { none: {} } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(slug: string, name: string) {
|
||||||
|
return prisma.tenant.create({ data: { slug, name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(tenantId: string, email: string, name: string) {
|
||||||
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||||
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ServiceCtx with the permission set of a standard role (plus/minus adjustments). */
|
||||||
|
export function ctxFor(tenantId: string, userId: string, role: RoleKey, adjust: { add?: string[]; remove?: string[] } = {}): ServiceCtx {
|
||||||
|
const perms = new Set<string>(ROLE_DEFS[role].permissions);
|
||||||
|
for (const p of adjust.add ?? []) perms.add(p);
|
||||||
|
for (const p of adjust.remove ?? []) perms.delete(p);
|
||||||
|
return { db: dbForTenant(tenantId), tenantId, userId, permissions: perms };
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderSeq = 0;
|
||||||
|
export async function createWorkOrder(
|
||||||
|
tenantId: string,
|
||||||
|
data: { customerId: string; siteId?: string | null; assignedTeamId?: string | null; status?: string; title?: string; followUpWork?: string | null; plannedStart?: Date },
|
||||||
|
) {
|
||||||
|
orderSeq++;
|
||||||
|
return prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
number: `ZZ-${Date.now().toString(36)}-${orderSeq}`,
|
||||||
|
customerId: data.customerId,
|
||||||
|
siteId: data.siteId ?? null,
|
||||||
|
assignedTeamId: data.assignedTeamId ?? null,
|
||||||
|
status: (data.status ?? "assigned") as never,
|
||||||
|
title: data.title ?? `Testauftrag ${orderSeq}`,
|
||||||
|
followUpWork: data.followUpWork ?? null,
|
||||||
|
plannedStart: data.plannedStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamWithMember(tenantId: string, name: string, memberUserId: string | null, leaderUserId: string | null = null) {
|
||||||
|
const team = await prisma.team.create({ data: { tenantId, name, leaderUserId } });
|
||||||
|
if (memberUserId) await prisma.teamMember.create({ data: { tenantId, teamId: team.id, userId: memberUserId, validFrom: new Date(Date.now() - 86_400_000) } });
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnect() {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { prisma };
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// L1 Stammdaten — Kunden, Ansprechpartner, vorläufige Kunden, Zusammenführen (Spec §7, US-003):
|
||||||
|
// (1) Anlage mit Nummernkreis, manuelle Nummer, Eindeutigkeit, Dublettenhinweis, Validierung, Audit
|
||||||
|
// (2) Bearbeiten, Ansprechpartner, vorläufig → aktiv
|
||||||
|
// (3) Mandantentrennung: Mandant B kann A weder lesen noch ändern noch zusammenführen
|
||||||
|
// (4) Rollen/Scope: Monteur ohne Zuweisung → not_found, mit Teamauftrag → sichtbar, keine Schreibrechte
|
||||||
|
// (5) Zusammenführen: nur mit customer:merge + Bestätigung, hängt Kontakte/Objekte/Aufträge/Dokumente um
|
||||||
|
// (6) Soft Delete blockiert bei offenen Aufträgen
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-stammdaten-customers.ts
|
||||||
|
|
||||||
|
import "dotenv/config"; // must run before any module that constructs the Prisma client
|
||||||
|
|
||||||
|
import {
|
||||||
|
confirmProvisionalCustomer,
|
||||||
|
createCustomer,
|
||||||
|
deleteCustomer,
|
||||||
|
getCustomer,
|
||||||
|
listCustomers,
|
||||||
|
updateCustomer,
|
||||||
|
} from "../src/server/services/customers/customers";
|
||||||
|
import { createContact, deleteContact, updateContact } from "../src/server/services/customers/contacts";
|
||||||
|
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||||
|
import {
|
||||||
|
checker,
|
||||||
|
cleanupTenants,
|
||||||
|
createTeamWithMember,
|
||||||
|
createTenant,
|
||||||
|
createUser,
|
||||||
|
createWorkOrder,
|
||||||
|
ctxFor,
|
||||||
|
disconnect,
|
||||||
|
prisma,
|
||||||
|
} from "./lib-stammdaten-fixtures";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l1-cust-a";
|
||||||
|
const SLUG_B = "zz-l1-cust-b";
|
||||||
|
const DOMAIN = "zz-l1-cust.test";
|
||||||
|
const c = checker("Kunden");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
||||||
|
const tA = await createTenant(SLUG_A, "L1 Kunden A");
|
||||||
|
const tB = await createTenant(SLUG_B, "L1 Kunden B");
|
||||||
|
const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A");
|
||||||
|
const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A");
|
||||||
|
const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B");
|
||||||
|
const ctxA = ctxFor(tA.id, boA.id, "backoffice");
|
||||||
|
const ctxB = ctxFor(tB.id, boB.id, "backoffice");
|
||||||
|
const ctxTech = ctxFor(tA.id, techA.id, "technician");
|
||||||
|
|
||||||
|
console.log("— (1) Anlage —");
|
||||||
|
const alpha = await createCustomer(ctxA, { companyName: "Alpha Sanitär GmbH", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg" });
|
||||||
|
c.ok(/^K-\d{5}$/.test(alpha.customerNumber ?? ""), `Kundennummer aus Nummernkreis (${alpha.customerNumber})`);
|
||||||
|
c.ok(alpha.createdById === boA.id && alpha.status === "active" && alpha.country === "DE", "createdById, Status aktiv, Land DE");
|
||||||
|
const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "create" } });
|
||||||
|
c.ok(!!audit?.after, "Audit-Eintrag (create, after) geschrieben");
|
||||||
|
|
||||||
|
const manual = await createCustomer(ctxA, { companyName: "Beta Elektro", customerNumber: "K-00002" });
|
||||||
|
c.ok(manual.customerNumber === "K-00002", "manuelle Kundennummer übernommen");
|
||||||
|
const auto = await createCustomer(ctxA, { lastName: "Gamma", firstName: "Gerda" });
|
||||||
|
c.ok(auto.customerNumber === "K-00003", `Nummernkreis überspringt manuell vergebene Nummer (${auto.customerNumber})`);
|
||||||
|
await c.expectServiceError(() => createCustomer(ctxA, { companyName: "Delta", customerNumber: "K-00002" }), "conflict", "doppelte Kundennummer → conflict", "number_taken");
|
||||||
|
const inB = await createCustomer(ctxB, { companyName: "Delta B", customerNumber: "K-00002" });
|
||||||
|
c.ok(inB.customerNumber === "K-00002", "gleiche Kundennummer in anderem Mandanten erlaubt (eindeutig je Mandant)");
|
||||||
|
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => createCustomer(ctxA, { companyName: "Alpha Sanitär", city: "Hamburg", street: "Hafen-Str.", houseNumber: "1", postalCode: "20457" }),
|
||||||
|
"conflict",
|
||||||
|
"mögliche Dublette ohne Bestätigung → conflict",
|
||||||
|
"possible_duplicates",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await createCustomer(ctxA, { companyName: "Alpha Sanitär" });
|
||||||
|
} catch (err) {
|
||||||
|
const cands = (err as { details?: { candidates?: { customerId: string }[] } }).details?.candidates ?? [];
|
||||||
|
c.ok(cands.some((x) => x.customerId === alpha.id), "Dublettenhinweis enthält den bestehenden Kunden");
|
||||||
|
}
|
||||||
|
const ack = await createCustomer(ctxA, { companyName: "Alpha Sanitär" }, { acknowledgeDuplicates: true });
|
||||||
|
c.ok(!!ack.id, "nach Bestätigung trotzdem angelegt (Nutzer entscheidet)");
|
||||||
|
|
||||||
|
await c.expectErrorName(() => createCustomer(ctxA, { city: "Nirgendwo" }), "ZodError", "ohne Firmenname/Nachname → Validierungsfehler");
|
||||||
|
await c.expectErrorName(() => createCustomer(ctxA, { companyName: "X", email: "kein-mail" }), "ZodError", "ungültige E-Mail → Validierungsfehler");
|
||||||
|
|
||||||
|
console.log("\n— (2) Bearbeiten, Ansprechpartner, vorläufig —");
|
||||||
|
const updated = await updateCustomer(ctxA, alpha.id, { email: "info@alpha.example", notes: null });
|
||||||
|
c.ok(updated.email === "info@alpha.example", "E-Mail geändert");
|
||||||
|
const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "update" } });
|
||||||
|
c.ok(!!upAudit?.before && !!upAudit?.after, "Audit-Eintrag (update, before/after)");
|
||||||
|
await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { companyName: null, lastName: null }), "invalid", "Name darf nicht vollständig entfernt werden", "name_required");
|
||||||
|
await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { customerNumber: "K-00002" }), "conflict", "Änderung auf vergebene Nummer → conflict", "number_taken");
|
||||||
|
|
||||||
|
const contact = await createContact(ctxA, alpha.id, { name: "Petra Planer", role: "Hausverwaltung", phone: "040 111", preferredChannel: "phone" });
|
||||||
|
const contact2 = await createContact(ctxA, alpha.id, { name: "Olaf Objekt", email: "olaf@alpha.example", preferredChannel: "email" });
|
||||||
|
c.ok(contact.customerId === alpha.id && contact2.preferredChannel === "email", "mehrere Ansprechpartner mit bevorzugtem Kontaktweg");
|
||||||
|
const contactUp = await updateContact(ctxA, contact.id, { name: "Petra Planer", preferredChannel: "mobile", mobile: "0170 1" });
|
||||||
|
c.ok(contactUp.preferredChannel === "mobile", "Ansprechpartner bearbeitet");
|
||||||
|
await c.expectErrorName(() => createContact(ctxA, alpha.id, { name: "X", preferredChannel: "fax" as never }), "ZodError", "ungültiger Kontaktweg → Validierungsfehler");
|
||||||
|
await deleteContact(ctxA, contact2.id);
|
||||||
|
const detail = await getCustomer(ctxA, alpha.id);
|
||||||
|
c.ok(detail.contacts.length === 1 && detail.contacts[0].id === contact.id, "gelöschter Ansprechpartner ausgeblendet (Soft Delete)");
|
||||||
|
|
||||||
|
const prov = await createCustomer(ctxA, { lastName: "Notdienstkunde", status: "provisional" }, { acknowledgeDuplicates: true });
|
||||||
|
c.ok(prov.status === "provisional" && prov.isProvisional, "vorläufiger Kunde angelegt");
|
||||||
|
const provList = await listCustomers(ctxA, { status: "provisional" });
|
||||||
|
c.ok(provList.items.some((x) => x.id === prov.id) && provList.items.every((x) => x.status === "provisional"), "Filter „vorläufig“");
|
||||||
|
const confirmed = await confirmProvisionalCustomer(ctxA, prov.id);
|
||||||
|
c.ok(confirmed.status === "active" && !confirmed.isProvisional, "vorläufig → aktiv bestätigt");
|
||||||
|
await c.expectServiceError(() => confirmProvisionalCustomer(ctxA, prov.id), "conflict", "erneutes Bestätigen → conflict", "not_provisional");
|
||||||
|
|
||||||
|
const search = await listCustomers(ctxA, { q: "alpha", pageSize: 1 });
|
||||||
|
c.ok(search.total === 2 && search.items.length === 1 && search.pageSize === 1, "Suche + Paginierung");
|
||||||
|
|
||||||
|
console.log("\n— (3) Mandantentrennung —");
|
||||||
|
await c.expectServiceError(() => getCustomer(ctxB, alpha.id), "not_found", "Mandant B liest Kunden von A → not_found");
|
||||||
|
await c.expectServiceError(() => updateCustomer(ctxB, alpha.id, { notes: "gehackt" }), "not_found", "Mandant B ändert Kunden von A → not_found");
|
||||||
|
await c.expectServiceError(() => createContact(ctxB, alpha.id, { name: "Fremd" }), "not_found", "Mandant B legt Kontakt an Kunde A an → not_found");
|
||||||
|
await c.expectServiceError(() => updateContact(ctxB, contact.id, { name: "Fremd" }), "not_found", "Mandant B ändert Kontakt von A → not_found");
|
||||||
|
await c.expectServiceError(() => confirmProvisionalCustomer(ctxB, prov.id), "not_found", "Mandant B bestätigt Kunden von A → not_found");
|
||||||
|
await c.expectServiceError(() => deleteCustomer(ctxB, alpha.id), "not_found", "Mandant B löscht Kunden von A → not_found");
|
||||||
|
const listB = await listCustomers(ctxB, { pageSize: 100 });
|
||||||
|
c.ok(listB.items.every((x) => x.id === inB.id), "Liste von B enthält nur eigene Kunden");
|
||||||
|
const stillA = await prisma.customer.findUnique({ where: { id: alpha.id } });
|
||||||
|
c.ok(stillA?.notes === null && stillA?.email === "info@alpha.example", "Kunde A unverändert");
|
||||||
|
|
||||||
|
console.log("\n— (4) Rollen/Scope Monteur —");
|
||||||
|
await c.expectServiceError(() => getCustomer(ctxTech, alpha.id), "not_found", "Monteur ohne Zuweisung → not_found");
|
||||||
|
c.ok((await listCustomers(ctxTech)).total === 0, "Monteur ohne Zuweisung sieht keine Kunden");
|
||||||
|
await c.expectServiceError(() => updateCustomer(ctxTech, alpha.id, { notes: "x" }), "forbidden", "Monteur darf Kunden nicht ändern → forbidden");
|
||||||
|
await c.expectServiceError(() => createCustomer(ctxTech, { companyName: "Monteurkunde" }), "forbidden", "Monteur darf keine Kunden anlegen → forbidden");
|
||||||
|
const team = await createTeamWithMember(tA.id, "Team Nord", techA.id);
|
||||||
|
const order = await createWorkOrder(tA.id, { customerId: alpha.id, assignedTeamId: team.id });
|
||||||
|
const techView = await getCustomer(ctxTech, alpha.id);
|
||||||
|
c.ok(techView.id === alpha.id, "Monteur sieht Kunden über Auftrag seines Teams");
|
||||||
|
c.ok((await listCustomers(ctxTech)).items.map((x) => x.id).join() === alpha.id, "Monteur-Liste nur mit erreichbaren Kunden");
|
||||||
|
|
||||||
|
console.log("\n— (5) Zusammenführen —");
|
||||||
|
const source = await createCustomer(ctxA, { companyName: "Quelle Haustechnik" });
|
||||||
|
const target = await createCustomer(ctxA, { companyName: "Ziel Haustechnik" });
|
||||||
|
const sContact = await createContact(ctxA, source.id, { name: "Kontakt Quelle" });
|
||||||
|
const sSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: source.id, name: "Objekt Quelle" } });
|
||||||
|
const sOrder = await createWorkOrder(tA.id, { customerId: source.id, siteId: sSite.id });
|
||||||
|
const sDoc = await prisma.document.create({
|
||||||
|
data: { tenantId: tA.id, customerId: source.id, category: "other", fileName: "a.pdf", storageKey: `${tA.id}/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: `lin-${source.id}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => mergeCustomers(ctxFor(tA.id, boA.id, "backoffice", { remove: ["customer:merge"] }), { sourceId: source.id, targetId: target.id, confirm: true }),
|
||||||
|
"forbidden",
|
||||||
|
"ohne customer:merge → forbidden",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(() => mergeCustomers(ctxTech, { sourceId: source.id, targetId: target.id, confirm: true }), "forbidden", "Monteur → forbidden");
|
||||||
|
await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: false as true }), "ZodError", "ohne Bestätigung → abgelehnt");
|
||||||
|
await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: source.id, confirm: true }), "ZodError", "Quelle = Ziel → abgelehnt");
|
||||||
|
await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: source.id, targetId: target.id, confirm: true }), "not_found", "Mandant B führt Kunden von A zusammen → not_found");
|
||||||
|
const bCustomer = await createCustomer(ctxB, { companyName: "B-Kunde" });
|
||||||
|
await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: bCustomer.id, confirm: true }), "not_found", "Ziel aus fremdem Mandanten → not_found (keine Kreuzung)");
|
||||||
|
await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: bCustomer.id, targetId: target.id, confirm: true }), "not_found", "Quelle B in Ziel A → not_found");
|
||||||
|
c.ok((await prisma.customer.findUnique({ where: { id: source.id } }))?.status === "active", "nach abgelehnten Versuchen ist die Quelle unverändert");
|
||||||
|
|
||||||
|
const result = await mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true });
|
||||||
|
c.ok(
|
||||||
|
result.moved.contacts === 1 && result.moved.sites === 1 && result.moved.workOrders === 1 && result.moved.documents === 1,
|
||||||
|
`umgehängt: ${JSON.stringify(result.moved)}`,
|
||||||
|
);
|
||||||
|
const [srcAfter, contactAfter, siteAfter, orderAfter, docAfter] = await Promise.all([
|
||||||
|
prisma.customer.findUnique({ where: { id: source.id } }),
|
||||||
|
prisma.contact.findUnique({ where: { id: sContact.id } }),
|
||||||
|
prisma.site.findUnique({ where: { id: sSite.id } }),
|
||||||
|
prisma.workOrder.findUnique({ where: { id: sOrder.id } }),
|
||||||
|
prisma.document.findUnique({ where: { id: sDoc.id } }),
|
||||||
|
]);
|
||||||
|
c.ok(srcAfter?.status === "merged" && srcAfter.mergedIntoId === target.id, "Quelle: status merged + mergedIntoId");
|
||||||
|
c.ok(contactAfter?.customerId === target.id && siteAfter?.customerId === target.id && docAfter?.customerId === target.id, "Kontakt, Objekt, Dokument hängen am Ziel");
|
||||||
|
c.ok(orderAfter?.customerId === target.id && orderAfter.version === sOrder.version + 1, "Auftrag am Ziel, Version erhöht (Offline-Konflikterkennung)");
|
||||||
|
const mergeAudits = await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: { in: [source.id, target.id] }, action: "update" } });
|
||||||
|
c.ok(mergeAudits === 2, "volles Audit: Einträge für Quelle und Ziel");
|
||||||
|
await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }), "conflict", "erneutes Zusammenführen → conflict", "already_merged");
|
||||||
|
c.ok(!(await listCustomers(ctxA, { pageSize: 100 })).items.some((x) => x.id === source.id), "Standardliste blendet zusammengeführte Kunden aus");
|
||||||
|
await c.expectServiceError(() => updateCustomer(ctxA, source.id, { notes: "x" }), "not_found", "zusammengeführter Kunde ist nicht mehr bearbeitbar");
|
||||||
|
|
||||||
|
console.log("\n— (6) Soft Delete —");
|
||||||
|
await c.expectServiceError(() => deleteCustomer(ctxA, alpha.id), "blocked", "Löschen bei offenem Auftrag → blocked", "open_work_orders");
|
||||||
|
await prisma.workOrder.update({ where: { id: order.id }, data: { status: "cancelled" } });
|
||||||
|
const deleted = await deleteCustomer(ctxA, alpha.id);
|
||||||
|
c.ok(!!deleted.deletedAt, "Kunde soft-gelöscht (deletedAt gesetzt)");
|
||||||
|
c.ok(!!(await prisma.customer.findUnique({ where: { id: alpha.id } })), "Datensatz physisch noch vorhanden");
|
||||||
|
await c.expectServiceError(() => getCustomer(ctxA, alpha.id), "not_found", "gelöschter Kunde → not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// L1 Stammdaten — Dokumentenablage (ARCHITEKTUR §4.3, Spec §24, §27.4):
|
||||||
|
// (1) Scanner/Typprüfung: Magic Bytes, Allowlist, Typ-Mismatch
|
||||||
|
// (2) Dateinamen-Normalisierung
|
||||||
|
// (3) storeFile: falscher Magic Byte / leer / zu groß / Sichtbarkeit → abgelehnt; SHA-256, Versionierung
|
||||||
|
// (4) Download-Autorisierung: backoffice_only für Monteur verweigert, fremder Mandant verweigert,
|
||||||
|
// Auftrags-Scope, Objekt-Scope, Teamleiter-Sichtbarkeit
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-stammdaten-documents.ts
|
||||||
|
|
||||||
|
import "dotenv/config"; // must run before any module that constructs the Prisma client
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { detectMime, MagicByteScanner } from "../src/server/services/documents/scanner";
|
||||||
|
import { normalizeFileName, storeFile, SIZE_LIMITS } from "../src/server/services/documents/store";
|
||||||
|
import { authorizeDocumentAccess, deleteDocument, getDownloadUrl, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access";
|
||||||
|
import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l1-doc-a";
|
||||||
|
const SLUG_B = "zz-l1-doc-b";
|
||||||
|
const DOMAIN = "zz-l1-doc.test";
|
||||||
|
const c = checker("Dokumente");
|
||||||
|
|
||||||
|
const PDF = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n");
|
||||||
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
|
const JPEG_HEAD = [0xff, 0xd8, 0xff, 0xe0];
|
||||||
|
const EXE = new TextEncoder().encode("MZ\x90\x00this is not an image");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("— (1) Scanner —");
|
||||||
|
const scanner = new MagicByteScanner();
|
||||||
|
c.ok(detectMime(PDF) === "application/pdf" && detectMime(PNG) === "image/png" && detectMime(new Uint8Array(JUMP())) === "image/jpeg", "Magic Bytes PDF/PNG/JPEG erkannt");
|
||||||
|
c.ok(detectMime(EXE) === null, "unbekannte Signatur (EXE) → nicht erkannt");
|
||||||
|
const okPdf = await scanner.scan({ bytes: PDF, declaredMime: "application/pdf", fileName: "a.pdf" });
|
||||||
|
c.ok(okPdf.ok && okPdf.kind === "pdf", "PDF mit passendem Typ akzeptiert");
|
||||||
|
const mismatch = await scanner.scan({ bytes: PNG, declaredMime: "application/pdf", fileName: "a.pdf" });
|
||||||
|
c.ok(!mismatch.ok && mismatch.reason === "type_mismatch", "PNG-Bytes als PDF deklariert → type_mismatch");
|
||||||
|
const exeAsPng = await scanner.scan({ bytes: EXE, declaredMime: "image/png", fileName: "x.png" });
|
||||||
|
c.ok(!exeAsPng.ok && exeAsPng.reason === "type_mismatch", "EXE als PNG deklariert → type_mismatch");
|
||||||
|
const txt = await scanner.scan({ bytes: PDF, declaredMime: "text/html", fileName: "x.html" });
|
||||||
|
c.ok(!txt.ok && txt.reason === "unsupported_type", "nicht erlaubter Typ → unsupported_type");
|
||||||
|
const jpgAlias = await scanner.scan({ bytes: new Uint8Array(JUMP()), declaredMime: "image/jpg", fileName: "x.jpg" });
|
||||||
|
c.ok(jpgAlias.ok && jpgAlias.detectedMime === "image/jpeg", "MIME-Alias image/jpg → image/jpeg");
|
||||||
|
|
||||||
|
console.log("\n— (2) Dateinamen —");
|
||||||
|
c.ok(normalizeFileName("../../etc/passwd") === "passwd", "Pfadanteile entfernt");
|
||||||
|
c.ok(normalizeFileName("C:\\Users\\x\\Plan <v2>.pdf") === "Plan _v2_.pdf", "Windows-Pfad und reservierte Zeichen");
|
||||||
|
c.ok(normalizeFileName("a\u0000b\u001f.pdf") === "ab.pdf", "Steuerzeichen entfernt");
|
||||||
|
c.ok(normalizeFileName(" ") === "datei" && normalizeFileName(".hidden") === "hidden", "leer → „datei“, führender Punkt entfernt");
|
||||||
|
const long = normalizeFileName(`${"x".repeat(300)}.pdf`);
|
||||||
|
c.ok(long.length === 180 && long.endsWith(".pdf"), "Länge begrenzt, Endung erhalten");
|
||||||
|
c.ok(normalizeFileName("Grundriss Erdgeschoß.pdf") === "Grundriss Erdgeschoß.pdf", "Umlaute und Leerzeichen bleiben lesbar");
|
||||||
|
|
||||||
|
console.log("\n— (3) storeFile —");
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
||||||
|
const tA = await createTenant(SLUG_A, "L1 Dokumente A");
|
||||||
|
const tB = await createTenant(SLUG_B, "L1 Dokumente B");
|
||||||
|
const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A");
|
||||||
|
const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X");
|
||||||
|
const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team");
|
||||||
|
const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Teamleiter X");
|
||||||
|
const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B");
|
||||||
|
const ctxA = ctxFor(tA.id, boA.id, "backoffice");
|
||||||
|
const ctxT1 = ctxFor(tA.id, tech1.id, "technician");
|
||||||
|
const ctxT2 = ctxFor(tA.id, tech2.id, "technician");
|
||||||
|
const ctxLead = ctxFor(tA.id, lead.id, "team-lead");
|
||||||
|
const ctxB = ctxFor(tB.id, boB.id, "backoffice");
|
||||||
|
|
||||||
|
const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Dokukunde" } });
|
||||||
|
const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Objekt mit Plänen" } });
|
||||||
|
const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id, lead.id);
|
||||||
|
const teamY = await createTeamWithMember(tA.id, "Team Y", null);
|
||||||
|
const woX = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id });
|
||||||
|
const woY = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: teamY.id });
|
||||||
|
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxA, { bytes: PNG, fileName: "plan.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"invalid",
|
||||||
|
"falscher Magic Byte (PNG als PDF) → abgelehnt",
|
||||||
|
"type_mismatch",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxA, { bytes: EXE, fileName: "virus.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"invalid",
|
||||||
|
"EXE mit .pdf-Endung → abgelehnt",
|
||||||
|
"type_mismatch",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxA, { bytes: new Uint8Array(0), fileName: "leer.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"invalid",
|
||||||
|
"leere Datei → abgelehnt",
|
||||||
|
"empty_file",
|
||||||
|
);
|
||||||
|
const bigJpeg = new Uint8Array(SIZE_LIMITS.image + 1);
|
||||||
|
bigJpeg.set(JUMP());
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxA, { bytes: bigJpeg, fileName: "gross.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"invalid",
|
||||||
|
"Bild über 15 MB → abgelehnt",
|
||||||
|
"too_large",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxT1, { bytes: PDF, fileName: "intern.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }),
|
||||||
|
"invalid",
|
||||||
|
"Monteur darf keine backoffice_only-Datei ablegen",
|
||||||
|
"visibility_not_allowed",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxT1, { bytes: PDF, fileName: "objekt.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"forbidden",
|
||||||
|
"Monteur ohne document:write darf nicht am Objekt ablegen",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxT1, { bytes: PDF, fileName: "fremd.pdf", declaredMime: "application/pdf", category: "photo", visibility: "team", links: { workOrderId: woY.id } }),
|
||||||
|
"not_found",
|
||||||
|
"Monteur legt an nicht sichtbarem Auftrag ab → not_found",
|
||||||
|
);
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }),
|
||||||
|
"invalid",
|
||||||
|
"Mandant B legt an Objekt von A ab → abgelehnt",
|
||||||
|
"site_not_found",
|
||||||
|
);
|
||||||
|
|
||||||
|
const planV1 = await storeFile(ctxA, { bytes: PDF, fileName: "../Grundriss EG.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", title: "Grundriss EG", links: { siteId: site.id } });
|
||||||
|
c.ok(planV1.version === 1 && planV1.fileName === "Grundriss EG.pdf" && planV1.mimeType === "application/pdf", "PDF gespeichert, Name normalisiert");
|
||||||
|
c.ok(planV1.checksum === createHash("sha256").update(PDF).digest("hex") && planV1.fileSize === PDF.byteLength, "SHA-256-Prüfsumme und Größe");
|
||||||
|
c.ok(planV1.storageKey.startsWith(`${tA.id}/`) || planV1.storageKey.startsWith("stub://"), "Storage-Key mandantenpräfixiert");
|
||||||
|
c.ok(planV1.uploadedById === boA.id, "Ersteller gespeichert");
|
||||||
|
c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "document", entityId: planV1.id, action: "create" } })), "Audit create");
|
||||||
|
const planV2 = await storeFile(ctxA, { bytes: PDF, fileName: "Grundriss EG v2.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", lineageId: planV1.lineageId });
|
||||||
|
c.ok(planV2.version === 2 && planV2.lineageId === planV1.lineageId && planV2.siteId === site.id, "neue Version: version 2, gleiche lineage, Zuordnung übernommen");
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", lineageId: planV1.lineageId }),
|
||||||
|
"invalid",
|
||||||
|
"Mandant B kann keine Version eines A-Dokuments anlegen",
|
||||||
|
"lineage_not_found",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n— (4) Download-Autorisierung —");
|
||||||
|
const internal = await storeFile(ctxA, { bytes: PDF, fileName: "kalkulation.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } });
|
||||||
|
const leadOnly = await storeFile(ctxA, { bytes: PDF, fileName: "teamleitung.pdf", declaredMime: "application/pdf", category: "other", visibility: "team_lead", links: { workOrderId: woX.id } });
|
||||||
|
const teamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "montage.pdf", declaredMime: "application/pdf", category: "assembly_instructions", visibility: "team", links: { workOrderId: woX.id } });
|
||||||
|
const otherTeamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "fremdteam.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: woY.id } });
|
||||||
|
const photo = await storeFile(ctxT1, { bytes: new Uint8Array(JUMP()), fileName: "foto.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woX.id } });
|
||||||
|
c.ok(photo.uploadedById === tech1.id, "Monteur legt Foto an sichtbarem Auftrag ab (field:execute)");
|
||||||
|
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, internal.id), "not_found", "backoffice_only für Monteur verweigert");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, leadOnly.id), "not_found", "team_lead-Dokument für Monteur verweigert");
|
||||||
|
c.ok((await authorizeDocumentAccess(ctxLead, leadOnly.id)).id === leadOnly.id, "team_lead-Dokument für Teamleiter erlaubt");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxLead, internal.id), "not_found", "backoffice_only auch für Teamleiter verweigert");
|
||||||
|
c.ok((await authorizeDocumentAccess(ctxT1, teamDoc.id)).id === teamDoc.id, "Team-Dokument am eigenen Auftrag erlaubt");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, otherTeamDoc.id), "not_found", "Team-Dokument an fremdem Auftrag verweigert (Auftrags-Scope)");
|
||||||
|
c.ok((await authorizeDocumentAccess(ctxT1, planV2.id)).id === planV2.id, "Objekt-Dokument erlaubt, wenn Objekt über Teamauftrag erreichbar");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, planV2.id), "not_found", "Objekt-Dokument für Monteur ohne Zuweisung verweigert");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, teamDoc.id), "not_found", "Auftrags-Dokument für Monteur ohne Zuweisung verweigert");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxB, teamDoc.id), "not_found", "fremder Mandant verweigert (Team-Dokument)");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxB, internal.id), "not_found", "fremder Mandant verweigert (Backoffice-Dokument)");
|
||||||
|
await c.expectServiceError(() => openDocumentContent(ctxB, planV1.id), "not_found", "fremder Mandant erhält keinen Inhalt");
|
||||||
|
c.ok((await authorizeDocumentAccess(ctxA, internal.id)).id === internal.id, "Backoffice mit document:read_internal liest backoffice_only");
|
||||||
|
await c.expectServiceError(
|
||||||
|
() => authorizeDocumentAccess(ctxFor(tA.id, boA.id, "backoffice", { remove: ["document:read"] }), teamDoc.id),
|
||||||
|
"not_found",
|
||||||
|
"ohne document:read → verweigert",
|
||||||
|
);
|
||||||
|
c.ok((await getDownloadUrl(ctxT1, teamDoc.id)) === `/files/${teamDoc.id}`, "Download-Link ist die interne Route /files/<id>");
|
||||||
|
|
||||||
|
const t1List = await listDocuments(ctxT1, { pageSize: 100 });
|
||||||
|
const t1Ids = new Set(t1List.items.map((d) => d.id));
|
||||||
|
c.ok(t1Ids.has(teamDoc.id) && t1Ids.has(planV2.id) && t1Ids.has(photo.id), "Monteur-Liste enthält sichtbare Dokumente");
|
||||||
|
c.ok(!t1Ids.has(internal.id) && !t1Ids.has(leadOnly.id) && !t1Ids.has(otherTeamDoc.id), "Monteur-Liste ohne interne/fremde Dokumente");
|
||||||
|
const latest = await listDocuments(ctxA, { siteId: site.id, latestOnly: true });
|
||||||
|
c.ok(latest.items.some((d) => d.id === planV2.id) && !latest.items.some((d) => d.id === planV1.id), "latestOnly zeigt nur die neueste Version");
|
||||||
|
const byCustomer = await listDocuments(ctxA, { customerId: customer.id, pageSize: 100 });
|
||||||
|
c.ok(byCustomer.items.some((d) => d.id === planV1.id) && byCustomer.items.some((d) => d.id === teamDoc.id), "Filter Kunde umfasst Objekt- und Auftragsdokumente");
|
||||||
|
c.ok((await listDocuments(ctxB, { pageSize: 100 })).total === 0, "Mandant B sieht keine Dokumente von A");
|
||||||
|
|
||||||
|
if (planV1.storageKey.startsWith(`${tA.id}/`)) {
|
||||||
|
const { content } = await openDocumentContent(ctxT1, planV1.id);
|
||||||
|
const buf = Buffer.from(await new Response(content.stream).arrayBuffer());
|
||||||
|
c.ok(buf.equals(Buffer.from(PDF)), "Inhalt aus dem Objektspeicher byte-identisch");
|
||||||
|
} else {
|
||||||
|
console.log("↷ Byte-Roundtrip übersprungen (kein S3 konfiguriert, Stub-Adapter)");
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.expectServiceError(() => updateDocumentMeta(ctxT1, teamDoc.id, { title: "x" }), "forbidden", "Monteur darf Metadaten nicht ändern");
|
||||||
|
await c.expectServiceError(() => updateDocumentMeta(ctxLead, teamDoc.id, { visibility: "backoffice_only" }), "forbidden", "Teamleiter ohne document:write → forbidden");
|
||||||
|
const meta = await updateDocumentMeta(ctxA, teamDoc.id, { title: "Montageanleitung", visibility: "team_lead" });
|
||||||
|
c.ok(meta.visibility === "team_lead", "Backoffice ändert Sichtbarkeit");
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, teamDoc.id), "not_found", "nach Umstellung auf team_lead für Monteur verborgen");
|
||||||
|
await c.expectServiceError(() => deleteDocument(ctxB, planV2.id), "not_found", "Mandant B löscht Dokument von A → not_found");
|
||||||
|
await deleteDocument(ctxA, planV2.id);
|
||||||
|
await c.expectServiceError(() => authorizeDocumentAccess(ctxA, planV2.id), "not_found", "soft-gelöschtes Dokument nicht mehr abrufbar");
|
||||||
|
c.ok(!!(await prisma.document.findUnique({ where: { id: planV2.id } })), "Datensatz bleibt physisch erhalten (Soft Delete)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal JPEG header bytes. */
|
||||||
|
function JUMP(): number[] {
|
||||||
|
return [...JPEG_HEAD, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// L1 Stammdaten — Dublettenprüfung (Spec §7.3, US-003):
|
||||||
|
// (1) Normalisierung: Kleinschreibung, Umlaute, Rechtsformen, Straße/Str., Telefon nur Ziffern
|
||||||
|
// (2) Scoring: Kundennummer, Name, Adresse, E-Mail, Telefon; Schwelle
|
||||||
|
// (3) findDuplicateCustomers gegen die DB inkl. Mandantentrennung und Monteur-Scope
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-stammdaten-duplicates.ts
|
||||||
|
|
||||||
|
import "dotenv/config"; // must run before any module that constructs the Prisma client
|
||||||
|
|
||||||
|
import {
|
||||||
|
DUPLICATE_THRESHOLD,
|
||||||
|
nameSimilarity,
|
||||||
|
normalizeCompanyName,
|
||||||
|
normalizePhone,
|
||||||
|
normalizeStreet,
|
||||||
|
normalizeText,
|
||||||
|
scoreDuplicate,
|
||||||
|
} from "../src/lib/customers/duplicates";
|
||||||
|
import { findDuplicateCustomers } from "../src/server/services/customers/duplicates";
|
||||||
|
import { checker, cleanupTenants, createTenant, createUser, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l1-dup-a";
|
||||||
|
const SLUG_B = "zz-l1-dup-b";
|
||||||
|
const DOMAIN = "zz-l1-dup.test";
|
||||||
|
const c = checker("Dublettenprüfung");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("— (1) Normalisierung —");
|
||||||
|
c.ok(normalizeText(" ÄÖÜ ßtraße ") === "aeoeue sstrasse", "Umlaute/ß transliteriert, getrimmt, klein");
|
||||||
|
c.ok(normalizeCompanyName("Müller Haustechnik GmbH & Co. KG") === "mueller haustechnik", "Rechtsform „GmbH & Co. KG“ entfernt");
|
||||||
|
c.ok(normalizeCompanyName("MUELLER Haustechnik AG") === "mueller haustechnik", "Rechtsform „AG“ entfernt, Groß/Klein egal");
|
||||||
|
c.ok(normalizeCompanyName("Bauer e.K.") === "bauer", "Rechtsform „e.K.“ entfernt");
|
||||||
|
c.ok(normalizeCompanyName("AGRAR Service") === "agrar service", "„AG“ innerhalb eines Wortes bleibt erhalten");
|
||||||
|
c.ok(normalizeStreet("Hafenstraße") === "hafenstr" && normalizeStreet("Hafen-Str.") === "hafenstr" && normalizeStreet("Hafen Strasse") === "hafenstr", "Straße/Str./Strasse vereinheitlicht");
|
||||||
|
c.ok(normalizePhone("+49 40 123456-0") === "0401234560" && normalizePhone("040 / 123 456 0") === "0401234560", "Telefon nur Ziffern, +49 → 0");
|
||||||
|
c.ok(normalizePhone("12") === "", "zu kurze Nummern werden ignoriert");
|
||||||
|
|
||||||
|
console.log("\n— (2) Scoring —");
|
||||||
|
const existing = {
|
||||||
|
customerNumber: "K-00042",
|
||||||
|
companyName: "Müller Haustechnik GmbH",
|
||||||
|
street: "Hafenstraße",
|
||||||
|
houseNumber: "12",
|
||||||
|
postalCode: "20457",
|
||||||
|
email: "info@mueller.example",
|
||||||
|
phone: "+49 40 123456-0",
|
||||||
|
};
|
||||||
|
const byNumber = scoreDuplicate({ customerNumber: "k-00042" }, existing);
|
||||||
|
c.ok(byNumber.score === 1 && byNumber.reasons.includes("customer_number"), "gleiche Kundennummer → Score 1");
|
||||||
|
const byNameAddress = scoreDuplicate({ companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }, existing);
|
||||||
|
c.ok(Math.abs(byNameAddress.score - 0.76) < 0.001 && byNameAddress.reasons.join() === "company_name,address", `Name + Adresse → 0.76 (${byNameAddress.score})`);
|
||||||
|
const byEmail = scoreDuplicate({ email: "INFO@mueller.example " }, existing);
|
||||||
|
c.ok(byEmail.score === 0.6 && byEmail.reasons.join() === "email", "E-Mail (Groß/Klein, Leerzeichen) → 0.6");
|
||||||
|
const byPhone = scoreDuplicate({ mobile: "040 1234560" }, existing);
|
||||||
|
c.ok(byPhone.score === 0.5 && byPhone.reasons.join() === "phone", "Telefon gegen Mobil-Eingabe → 0.5");
|
||||||
|
const similar = scoreDuplicate({ companyName: "Müller Haustechnick" }, existing);
|
||||||
|
c.ok(similar.reasons.includes("company_name") && similar.score === 0.45, `ähnlicher Name (Tippfehler) → 0.45 (${similar.score})`);
|
||||||
|
c.ok(nameSimilarity("abc", "xyz") === 0, "unähnliche Namen → Ähnlichkeit 0");
|
||||||
|
const unrelated = scoreDuplicate({ companyName: "Nordlicht Elektro", postalCode: "10115", street: "Invalidenstraße" }, existing);
|
||||||
|
c.ok(unrelated.score === 0 && unrelated.score < DUPLICATE_THRESHOLD, "fremder Kunde → Score 0 (unter Schwelle)");
|
||||||
|
const all = scoreDuplicate({ ...existing }, existing);
|
||||||
|
c.ok(all.score === 1 && all.reasons.length === 5, "alle Merkmale gleich → 5 Gründe, Score 1");
|
||||||
|
|
||||||
|
console.log("\n— (3) findDuplicateCustomers (DB) —");
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
||||||
|
const tA = await createTenant(SLUG_A, "L1 Dubletten A");
|
||||||
|
const tB = await createTenant(SLUG_B, "L1 Dubletten B");
|
||||||
|
const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A");
|
||||||
|
const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A");
|
||||||
|
const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B");
|
||||||
|
|
||||||
|
const custA = await prisma.customer.create({ data: { tenantId: tA.id, ...existing } });
|
||||||
|
const otherA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00043", companyName: "Schmidt Bedachung", city: "Kiel", postalCode: "24103" } });
|
||||||
|
const mergedA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00044", companyName: "Müller Haustechnik", status: "merged" } });
|
||||||
|
const custB = await prisma.customer.create({ data: { tenantId: tB.id, ...existing } });
|
||||||
|
|
||||||
|
const ctxA = ctxFor(tA.id, boA.id, "backoffice");
|
||||||
|
const ctxB = ctxFor(tB.id, boB.id, "backoffice");
|
||||||
|
|
||||||
|
const hits = await findDuplicateCustomers(ctxA, { companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" });
|
||||||
|
c.ok(hits.length === 1 && hits[0].customerId === custA.id, "Treffer in Mandant A gefunden (Name + Adresse)");
|
||||||
|
c.ok(hits[0]?.score >= 0.76 && hits[0]?.displayName === existing.companyName, "Treffer mit Score und Anzeigename");
|
||||||
|
c.ok(!hits.some((h) => h.customerId === custB.id), "Mandantentrennung: gleicher Kunde in Mandant B wird NICHT gefunden");
|
||||||
|
c.ok(!hits.some((h) => h.customerId === mergedA.id), "zusammengeführte Kunden werden nicht vorgeschlagen");
|
||||||
|
c.ok(!hits.some((h) => h.customerId === otherA.id), "unähnlicher Kunde nicht vorgeschlagen");
|
||||||
|
|
||||||
|
const phoneHits = await findDuplicateCustomers(ctxA, { phone: "040 1234560" });
|
||||||
|
c.ok(phoneHits.length === 1 && phoneHits[0].customerId === custA.id && phoneHits[0].reasons.join() === "phone", "Treffer nur über formatiert gespeicherte Telefonnummer");
|
||||||
|
|
||||||
|
const numberHits = await findDuplicateCustomers(ctxA, { customerNumber: "k-00042" });
|
||||||
|
c.ok(numberHits[0]?.customerId === custA.id && numberHits[0]?.score === 1, "Treffer über Kundennummer (Score 1)");
|
||||||
|
|
||||||
|
const excluded = await findDuplicateCustomers(ctxA, { customerNumber: "K-00042" }, { excludeId: custA.id });
|
||||||
|
c.ok(excluded.length === 0, "excludeId schließt den eigenen Datensatz aus");
|
||||||
|
|
||||||
|
const hitsB = await findDuplicateCustomers(ctxB, { customerNumber: "K-00043" });
|
||||||
|
c.ok(hitsB.length === 0, "Mandant B findet Kunden von A nicht über deren Kundennummer");
|
||||||
|
|
||||||
|
const techHits = await findDuplicateCustomers(ctxFor(tA.id, techA.id, "technician"), { customerNumber: "K-00042" });
|
||||||
|
c.ok(techHits.length === 0, "Monteur ohne sichtbaren Auftrag erhält keine Kundentreffer (Scope)");
|
||||||
|
|
||||||
|
c.ok((await findDuplicateCustomers(ctxA, {})).length === 0, "leerer Kandidat → keine Treffer");
|
||||||
|
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
c.ok(false, "unerwarteter Fehler");
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch(() => {});
|
||||||
|
const failures = c.finish();
|
||||||
|
await disconnect();
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// L1 Stammdaten — Objekte und Objekt-Historie (Spec §8, US-005, US-011):
|
||||||
|
// (1) Objekt anlegen/ändern inkl. Validierung Kunde/Kontakt, Kartenlink, Soft Delete
|
||||||
|
// (2) Historie: chronologisch, Arbeiten, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten
|
||||||
|
// (3) Rollen/Scope: Monteur sieht nur freigegebene Einsätze; ohne sichtbaren Auftrag → not_found
|
||||||
|
// (4) Mandantentrennung
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-stammdaten-sites.ts
|
||||||
|
|
||||||
|
import "dotenv/config"; // must run before any module that constructs the Prisma client
|
||||||
|
|
||||||
|
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||||
|
import { siteMapUrl } from "../src/server/services/sites/map-link";
|
||||||
|
import { createSite, deleteSite, getSite, listSites, updateSite } from "../src/server/services/sites/sites";
|
||||||
|
import {
|
||||||
|
checker,
|
||||||
|
cleanupTenants,
|
||||||
|
createTeamWithMember,
|
||||||
|
createTenant,
|
||||||
|
createUser,
|
||||||
|
createWorkOrder,
|
||||||
|
ctxFor,
|
||||||
|
disconnect,
|
||||||
|
prisma,
|
||||||
|
} from "./lib-stammdaten-fixtures";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-l1-site-a";
|
||||||
|
const SLUG_B = "zz-l1-site-b";
|
||||||
|
const DOMAIN = "zz-l1-site.test";
|
||||||
|
const c = checker("Objekte & Historie");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await cleanupTenants([SLUG_A, SLUG_B], DOMAIN);
|
||||||
|
const tA = await createTenant(SLUG_A, "L1 Objekte A");
|
||||||
|
const tB = await createTenant(SLUG_B, "L1 Objekte B");
|
||||||
|
const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A");
|
||||||
|
const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X");
|
||||||
|
const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team");
|
||||||
|
const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B");
|
||||||
|
const ctxA = ctxFor(tA.id, boA.id, "backoffice");
|
||||||
|
const ctxT1 = ctxFor(tA.id, tech1.id, "technician");
|
||||||
|
const ctxT2 = ctxFor(tA.id, tech2.id, "technician");
|
||||||
|
const ctxB = ctxFor(tB.id, boB.id, "backoffice");
|
||||||
|
|
||||||
|
const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Objektkunde" } });
|
||||||
|
const otherCustomer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-2", companyName: "Anderer Kunde" } });
|
||||||
|
const otherContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: otherCustomer.id, name: "Fremdkontakt" } });
|
||||||
|
const ownContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Hausmeister" } });
|
||||||
|
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-1", companyName: "B-Kunde" } });
|
||||||
|
|
||||||
|
console.log("— (1) Objekte —");
|
||||||
|
const site = await createSite(ctxA, {
|
||||||
|
customerId: customer.id,
|
||||||
|
name: "Wohnanlage Süd",
|
||||||
|
street: "Hafenstraße",
|
||||||
|
houseNumber: "12",
|
||||||
|
postalCode: "20457",
|
||||||
|
city: "Hamburg",
|
||||||
|
contactId: ownContact.id,
|
||||||
|
accessNotes: "Schlüssel beim Hausmeister",
|
||||||
|
parkingNotes: "Hof",
|
||||||
|
safetyNotes: "Asbest im Keller",
|
||||||
|
latitude: "53,5413" as never,
|
||||||
|
longitude: 9.9841,
|
||||||
|
});
|
||||||
|
c.ok(site.customerId === customer.id && site.latitude === 53.5413 && site.safetyNotes === "Asbest im Keller", "Objekt mit Hinweisen und Koordinaten (Komma-Dezimal) angelegt");
|
||||||
|
c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "site", entityId: site.id, action: "create" } })), "Audit create");
|
||||||
|
await c.expectServiceError(() => createSite(ctxA, { customerId: customer.id, name: "X", contactId: otherContact.id }), "invalid", "Kontakt eines anderen Kunden → invalid", "contact_mismatch");
|
||||||
|
await c.expectServiceError(() => createSite(ctxA, { customerId: customerB.id, name: "X" }), "invalid", "Kunde aus fremdem Mandanten → invalid", "customer_not_found");
|
||||||
|
await c.expectServiceError(() => createSite(ctxT1, { customerId: customer.id, name: "X" }), "forbidden", "Monteur darf keine Objekte anlegen");
|
||||||
|
await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "" }), "ZodError", "ohne Bezeichnung → Validierungsfehler");
|
||||||
|
await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "X", latitude: 123 }), "ZodError", "Breitengrad außerhalb −90..90 → Validierungsfehler");
|
||||||
|
|
||||||
|
const moved = await updateSite(ctxA, site.id, { technicalNotes: "Heizung Baujahr 2004" });
|
||||||
|
c.ok(moved.technicalNotes === "Heizung Baujahr 2004" && moved.contactId === ownContact.id, "Objekt geändert, Kontakt bleibt");
|
||||||
|
const url = siteMapUrl(site);
|
||||||
|
c.ok(!!url && url.startsWith("https://www.openstreetmap.org/?mlat=53.541300"), `Kartenlink aus Koordinaten (${url})`);
|
||||||
|
const addrUrl = siteMapUrl({ street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" });
|
||||||
|
c.ok(addrUrl === `https://www.openstreetmap.org/search?query=${encodeURIComponent("Hafenstraße 12, 20457 Hamburg")}`, "Kartenlink aus Adresse (kein Embed)");
|
||||||
|
c.ok(siteMapUrl({ street: "Nur Straße" }) === null, "ohne Ort/PLZ kein Kartenlink");
|
||||||
|
|
||||||
|
console.log("\n— (2) Historie (Backoffice) —");
|
||||||
|
const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id);
|
||||||
|
const teamY = await createTeamWithMember(tA.id, "Team Y", null);
|
||||||
|
|
||||||
|
// WO1: freigegeben, Team Y (nicht Team des Monteurs), mit allen Nachweisen
|
||||||
|
const wo1 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "released_for_billing", title: "Wartung Heizung", followUpWork: "Nachkontrolle im Herbst" });
|
||||||
|
const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo1.id, userId: boA.id, startedAt: new Date("2026-03-01T08:00:00Z"), status: "ended" } });
|
||||||
|
await prisma.activityNote.createMany({
|
||||||
|
data: [
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, kind: "work_done", text: "Heizung gewartet" },
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, kind: "follow_up", text: "Ventil tauschen" },
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, kind: "general", text: "INTERN nicht anzeigen" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await prisma.materialUsage.createMany({
|
||||||
|
data: [
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, workSessionId: session.id, name: "Ventil", unit: "Stk", actualQuantity: 1, usageStatus: "fully_used" },
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, name: "ventil ", unit: "Stk", actualQuantity: 1.5, usageStatus: "additional" },
|
||||||
|
{ tenantId: tA.id, workOrderId: wo1.id, name: "Dichtung", unit: "Stk", actualQuantity: 4, usageStatus: "not_used" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const doc = await prisma.document.create({ data: { tenantId: tA.id, workOrderId: wo1.id, category: "photo", fileName: `p${i}.jpg`, storageKey: `${tA.id}/p${i}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0", lineageId: `lin-p${i}-${wo1.id}` } });
|
||||||
|
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo1.id, documentId: doc.id, takenAt: new Date() } });
|
||||||
|
}
|
||||||
|
const report1 = await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo1.id, type: "completion", reportDate: new Date("2026-03-01"), lineageId: `r-${wo1.id}`, status: "approved", content: {} } });
|
||||||
|
await prisma.signature.create({ data: { tenantId: tA.id, reportId: report1.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } });
|
||||||
|
|
||||||
|
// WO2: Team X (Monteur 1), in Arbeit, nicht freigegeben
|
||||||
|
const wo2 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id, status: "in_progress", title: "Leitung verlegen", plannedStart: new Date("2026-04-10T07:00:00Z") });
|
||||||
|
await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo2.id, kind: "work_done", text: "Leitung verlegt" } });
|
||||||
|
// WO3: Team Y, Bericht nur eingereicht
|
||||||
|
const wo3 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "in_review", plannedStart: new Date("2026-02-01T07:00:00Z") });
|
||||||
|
await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo3.id, type: "daily", reportDate: new Date("2026-02-01"), lineageId: `r-${wo3.id}`, status: "submitted", content: {} } });
|
||||||
|
// WO an anderem Objekt darf nicht erscheinen
|
||||||
|
const otherSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Anderes Objekt" } });
|
||||||
|
await createWorkOrder(tA.id, { customerId: customer.id, siteId: otherSite.id, assignedTeamId: teamY.id });
|
||||||
|
|
||||||
|
const all = await getSiteHistory(ctxA, site.id, { onlyApproved: false });
|
||||||
|
c.ok(all.total === 3 && !all.onlyApproved, `Backoffice sieht alle 3 Einsätze am Objekt (${all.total})`);
|
||||||
|
c.ok(all.items.map((e) => e.workOrderId).join() === [wo2.id, wo1.id, wo3.id].join(), "chronologisch, neueste zuerst (Einsatzbeginn/Termin)");
|
||||||
|
const e1 = all.items.find((e) => e.workOrderId === wo1.id)!;
|
||||||
|
c.ok(e1.workDone.join() === "Heizung gewartet" && !e1.summary.includes("INTERN"), "durchgeführte Arbeiten aus work_done, interne Notizen nicht enthalten");
|
||||||
|
c.ok(e1.materials.length === 1 && e1.materials[0].quantity === 2.5 && e1.materials[0].unit === "Stk", `Material aggregiert, not_used ausgeschlossen (${JSON.stringify(e1.materials)})`);
|
||||||
|
c.ok(e1.photoCount === 2 && e1.signed && e1.approvedReports.length === 1 && e1.approvedReports[0].id === report1.id, "Fotoanzahl, Unterschrift, freigegebener Bericht");
|
||||||
|
c.ok(e1.hasOpenFollowUp && e1.followUps.length === 2, "offene Folgearbeiten (followUpWork + follow_up-Notiz) markiert");
|
||||||
|
c.ok(e1.team === "Team Y" && e1.date.toISOString() === "2026-03-01T08:00:00.000Z", "Team und Datum (Einsatzbeginn)");
|
||||||
|
const e3 = all.items.find((e) => e.workOrderId === wo3.id)!;
|
||||||
|
c.ok(e3.approvedReports.length === 0 && !e3.hasOpenFollowUp, "eingereichter Bericht zählt nicht als freigegeben");
|
||||||
|
const approvedOnly = await getSiteHistory(ctxA, site.id, { onlyApproved: true });
|
||||||
|
c.ok(approvedOnly.total === 1 && approvedOnly.items[0].workOrderId === wo1.id, "onlyApproved=true → nur freigegebener Einsatz");
|
||||||
|
const paged = await getSiteHistory(ctxA, site.id, { page: 2, pageSize: 2 });
|
||||||
|
c.ok(paged.total === 3 && paged.items.length === 1, "Paginierung der Historie");
|
||||||
|
|
||||||
|
console.log("\n— (3) Rollen/Scope —");
|
||||||
|
const t1 = await getSiteHistory(ctxT1, site.id, { onlyApproved: false });
|
||||||
|
c.ok(t1.onlyApproved && t1.total === 1 && t1.items[0].workOrderId === wo1.id, "Monteur (Team X) erhält nur freigegebene Einsätze, auch mit onlyApproved=false");
|
||||||
|
c.ok(!t1.items.some((e) => e.workOrderId === wo2.id || e.workOrderId === wo3.id), "nicht freigegebene Einsätze bleiben für Monteur verborgen");
|
||||||
|
await c.expectServiceError(() => getSiteHistory(ctxT2, site.id), "not_found", "Monteur ohne sichtbaren Auftrag am Objekt → not_found");
|
||||||
|
await c.expectServiceError(() => getSite(ctxT2, site.id), "not_found", "Monteur ohne Zuweisung liest Objekt → not_found");
|
||||||
|
c.ok((await getSite(ctxT1, site.id)).id === site.id, "Monteur mit Teamauftrag liest Objekt");
|
||||||
|
const t1List = await listSites(ctxT1, { pageSize: 50 });
|
||||||
|
c.ok(t1List.items.map((s) => s.id).join() === site.id, "Monteur-Objektliste nur mit erreichbaren Objekten");
|
||||||
|
await c.expectServiceError(() => updateSite(ctxT1, site.id, { name: "x" }), "forbidden", "Monteur darf Objekt nicht ändern");
|
||||||
|
await c.expectServiceError(() => getSiteHistory(ctxFor(tA.id, tech1.id, "technician", { remove: ["site:read"] }), site.id), "forbidden", "ohne site:read → forbidden");
|
||||||
|
|
||||||
|
console.log("\n— (4) Mandantentrennung —");
|
||||||
|
await c.expectServiceError(() => getSite(ctxB, site.id), "not_found", "Mandant B liest Objekt von A → not_found");
|
||||||
|
await c.expectServiceError(() => getSiteHistory(ctxB, site.id), "not_found", "Mandant B liest Historie von A → not_found");
|
||||||
|
await c.expectServiceError(() => updateSite(ctxB, site.id, { name: "gehackt" }), "not_found", "Mandant B ändert Objekt von A → not_found");
|
||||||
|
await c.expectServiceError(() => deleteSite(ctxB, site.id), "not_found", "Mandant B löscht Objekt von A → not_found");
|
||||||
|
c.ok((await listSites(ctxB)).total === 0, "Liste von B leer");
|
||||||
|
c.ok((await prisma.site.findUnique({ where: { id: site.id } }))?.name === "Wohnanlage Süd", "Objekt A unverändert");
|
||||||
|
|
||||||
|
console.log("\n— Soft Delete —");
|
||||||
|
await c.expectServiceError(() => deleteSite(ctxA, site.id), "blocked", "Löschen bei offenen Aufträgen → blocked", "open_work_orders");
|
||||||
|
const emptySite = await createSite(ctxA, { customerId: customer.id, name: "Leeres Objekt" });
|
||||||
|
const del = await deleteSite(ctxA, emptySite.id);
|
||||||
|
c.ok(!!del.deletedAt, "Objekt ohne Aufträge soft-gelöscht");
|
||||||
|
await c.expectServiceError(() => getSite(ctxA, emptySite.id), "not_found", "gelöschtes Objekt → not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user