230 lines
11 KiB
TypeScript
230 lines
11 KiB
TypeScript
// 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:<message>". */
|
|
export async function codeOf(p: Promise<unknown> | (() => Promise<unknown>)): Promise<string> {
|
|
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<unknown>, codes: string | string[], msg: string): Promise<string> {
|
|
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<string>(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<string, RoleKey>;
|
|
export type Persona = keyof typeof PERSONAS;
|
|
|
|
export type TenantFixture = {
|
|
slug: string;
|
|
tenantId: string;
|
|
users: Record<Persona, { id: string; email: string; identityId: string }>;
|
|
ctx: Record<Persona, ServiceCtx>;
|
|
teamId: string;
|
|
team2Id: string;
|
|
customerId: string;
|
|
contactId: string;
|
|
siteId: string;
|
|
};
|
|
|
|
export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise<TenantFixture> {
|
|
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<RoleKey, string>;
|
|
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<void> {
|
|
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<void>): Promise<never> {
|
|
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<Buffer> {
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#cfd8e3"/><text x="320" y="250" font-size="40" text-anchor="middle" font-family="sans-serif">${label}</text></svg>`;
|
|
return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer();
|
|
}
|
|
|
|
export async function pngBytes(): Promise<Buffer> {
|
|
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`);
|
|
}
|