// L10a shared fixture for the acceptance tests (scripts/test-e2e-*.ts, scripts/test-security-*.ts). // Not a test itself (the runner only picks up scripts/test-*.ts at top level). // // createTenant(slug) builds a complete zz test tenant exactly like a provisioned one: tenant // settings, the four standard roles WITH their permissions in the DB (needed by recipient // resolution, conflict re-apply and requireApiContext), users per role, Team Nord (lead + 2 // technicians), Team Süd (lead2 + outsider), a customer with contact and a site. // cleanupTenants() removes every row of all tenant models (FK-safe order) and the test identities. import "dotenv/config"; import sharp from "sharp"; import { ZodError } from "zod"; 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 const E2E_DOMAIN = "@zz-qualitaet.test"; let failureCount = 0; export const failures = () => failureCount; export function ok(cond: boolean, msg: string): boolean { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failureCount++; return cond; } export function section(title: string) { console.log(`\n— ${title} —`); } /** Error code of a promise: "ok", a ServiceError code, or "error:". */ export async function codeOf(p: Promise | (() => Promise)): Promise { try { await (typeof p === "function" ? p() : p); return "ok"; } catch (e) { if (e instanceof ServiceError) return e.code; // Zod validation errors surface as `invalid` in every adapter (toErrorResponse, action state) if (e instanceof ZodError) return "invalid"; const code = (e as { code?: unknown }).code; if (typeof code === "string" && ["not_found", "forbidden", "invalid", "conflict", "blocked"].includes(code)) return code; return `error:${(e as Error).message?.split("\n")[0]}`; } } export async function expectCode(fn: () => Promise, codes: string | string[], msg: string): Promise { const got = await codeOf(fn); const wanted = Array.isArray(codes) ? codes : [codes]; ok(wanted.includes(got), `${msg}${wanted.includes(got) ? "" : ` — erhalten ${got}, erwartet ${wanted.join("|")}`}`); return got; } export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; } const PERSONAS = { admin: "tenant-admin", backoffice: "backoffice", lead: "team-lead", lead2: "team-lead", tech: "technician", tech2: "technician", outsider: "technician", } as const satisfies Record; export type Persona = keyof typeof PERSONAS; export type TenantFixture = { slug: string; tenantId: string; users: Record; ctx: Record; teamId: string; team2Id: string; customerId: string; contactId: string; siteId: string; }; export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise { const tenant = await prisma.tenant.create({ data: { name: opts.orgName ?? `ZZ Qualität ${slug}`, slug } }); await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: opts.orgName ?? `ZZ Qualität ${slug} GmbH`, timezone: "Europe/Berlin", address: "Prüfweg 1, 20095 Hamburg" } }); const roleIds = {} as Record; for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) { const perms = await Promise.all(ROLE_DEFS[key].permissions.map((p) => prisma.permission.upsert({ where: { key: p }, update: {}, create: { key: p } }))); const role = await prisma.role.create({ data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: perms.map((p) => ({ permissionId: p.id })) } }, }); roleIds[key] = role.id; } const users = {} as TenantFixture["users"]; const ctx = {} as TenantFixture["ctx"]; for (const persona of Object.keys(PERSONAS) as Persona[]) { const email = `${persona}-${slug}${E2E_DOMAIN}`; const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); const user = await prisma.user.create({ data: { tenantId: tenant.id, identityId: identity.id, email, name: `${persona} ${slug}`, userRoles: { create: [{ roleId: roleIds[PERSONAS[persona]] }] } }, }); users[persona] = { id: user.id, email, identityId: identity.id }; ctx[persona] = ctxFor(tenant.id, user.id, PERSONAS[persona]); } const since = new Date(Date.now() - 30 * 86400_000); const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } }); await prisma.teamMember.createMany({ data: [users.tech.id, users.tech2.id].map((userId) => ({ tenantId: tenant.id, teamId: team.id, userId, validFrom: since })), }); const team2 = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd", leaderUserId: users.lead2.id } }); await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team2.id, userId: users.outsider.id, validFrom: since } }); const customer = await prisma.customer.create({ data: { tenantId: tenant.id, customerNumber: `K-${slug}`, companyName: `Hausverwaltung ${slug} GmbH`, street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "040 555 1000", email: `kontakt@${slug}.example` }, }); const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: "Frau Prüf", role: "Objektbetreuung", phone: "040 555 1001" } }); const site = await prisma.site.create({ data: { tenantId: tenant.id, customerId: customer.id, contactId: contact.id, name: `Wohnanlage ${slug}`, street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" }, }); return { slug, tenantId: tenant.id, users, ctx, teamId: team.id, team2Id: team2.id, customerId: customer.id, contactId: contact.id, siteId: site.id }; } /** Deletes all rows of the given tenants (all TENANT_MODELS, FK-safe) plus their test identities. */ export async function cleanupTenants(slugs: string[]): Promise { const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); const ids = tenants.map((t) => t.id); if (ids.length) { const w = { where: { tenantId: { in: ids } } }; await prisma.syncOperation.deleteMany(w); await prisma.notification.deleteMany(w); await prisma.notificationPreference.deleteMany(w); await prisma.mailLog.deleteMany(w); await prisma.authToken.deleteMany(w); await prisma.aiGeneration.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.updateMany({ ...w, data: { workOrderId: null, siteId: null, customerId: null } }); await prisma.workOrder.deleteMany(w); await prisma.importJob.deleteMany(w); await prisma.document.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.tenantModule.deleteMany(w); await prisma.tenantSettings.deleteMany(w); await prisma.auditLog.deleteMany(w); await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } }); await prisma.user.deleteMany(w); await prisma.rolePermission.deleteMany({ where: { role: { tenantId: { in: ids } } } }); await prisma.role.deleteMany(w); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } await prisma.identity.deleteMany({ where: { email: { endsWith: E2E_DOMAIN }, memberships: { none: {} } } }); } /** Runs `body` between setup/cleanup, prints the summary and exits with the right code. */ export async function runSuite(name: string, slugs: string[], body: () => Promise): Promise { let crashed = false; try { await cleanupTenants(slugs); await body(); } catch (err) { crashed = true; console.error(`\n✗ FEHLER ${name} abgebrochen:`, err); } finally { await cleanupTenants(slugs).catch((e) => console.error("cleanup failed", e)); try { const { closeJobQueues } = await import("../../src/server/jobs/queues"); await closeJobQueues(); } catch { /* queues optional */ } try { const { closeQueues } = await import("../../src/server/mail/queue"); await closeQueues(); } catch { /* mail queue optional */ } await prisma.$disconnect(); } const n = failures() + (crashed ? 1 : 0); console.log(n === 0 ? `\nOK — ${name}: alle Nachweise erfüllt.` : `\n${n} FEHLER in ${name}.`); process.exit(n === 0 ? 0 : 1); } // ---------- binary fixtures ---------- export async function jpegBytes(label = "Foto"): Promise { const svg = `${label}`; return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer(); } export async function pngBytes(): Promise { return sharp({ create: { width: 300, height: 100, channels: 3, background: { r: 255, g: 255, b: 255 } } }).png().toBuffer(); } /** Local calendar day key (Europe/Berlin) with an offset in days. */ export function dayKey(offsetDays = 0): string { const d = new Date(Date.now() + offsetDays * 86400_000); return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" }).format(d); } /** Instant at a given Berlin wall-clock hour of the day `offsetDays` from today. */ export function berlinAt(offsetDays: number, hh: number, mm = 0): Date { const key = dayKey(offsetDays); // Europe/Berlin is UTC+1 or UTC+2; resolve the offset of that day at noon. const probe = new Date(`${key}T12:00:00Z`); const berlinNoon = Number(new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Berlin", hour: "2-digit", hour12: false }).format(probe)); const offsetHours = berlinNoon - 12; return new Date(`${key}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00${offsetHours >= 0 ? "+" : "-"}${String(Math.abs(offsetHours)).padStart(2, "0")}:00`); }