Files
craftvia/scripts/lib/e2e-fixture.ts
msolarczekandClaude Opus 5 06d320908f L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00

231 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.lotseChatSeat.deleteMany(w); // L17 Pakete
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`);
}