L10a Qualität & Abnahmetests: E2E-Prozesstests (regulärer Auftrag, mehrtägig, Notdienst, Offline-Sync, Pflichtfotos/Unterschrift, Dubletten, Mandantentrennung systematisch)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
// 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`);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// L10a E2E §43.3 / §39 „Notdiensteinsatz" — Monteur legt Notdienst mit vorläufigem Kunden an → Einsatz →
|
||||
// Abschluss ohne Unterschrift (Kunde abwesend) → Backoffice wird informiert → Backoffice ordnet den
|
||||
// bestehenden Kunden zu, bestätigt das Objekt, ergänzt den Auftrag → Berichtsfreigabe → Abrechnung.
|
||||
// Dazu Rollen (Monteur/Teamleiter dürfen nicht prüfen) und Mandantentrennung.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-emergency.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { createEmergencyOrder } from "../src/server/services/emergency/create";
|
||||
import {
|
||||
assignEmergencyToCustomer,
|
||||
completeEmergencyOrderData,
|
||||
correctEmergencySite,
|
||||
getEmergencyReview,
|
||||
listEmergencyReviews,
|
||||
releaseEmergencyForBilling,
|
||||
} from "../src/server/services/emergency/review";
|
||||
import { markBilled } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession } from "../src/server/services/field/sessions";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { getCustomer } from "../src/server/services/customers/customers";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import { materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload } from "../src/lib/sync/ops";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-nd-a";
|
||||
const SLUG_B = "zz-q-e2e-nd-b";
|
||||
const noPdf = { dispatchPdf: async () => undefined };
|
||||
|
||||
runSuite("E2E Notdienst", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
section("1. Monteur legt Notdienst mit vorläufigem Kunden an");
|
||||
const startedAt = new Date(Date.now() - 2 * 3600_000);
|
||||
const r = await createEmergencyOrder(tech, {
|
||||
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
|
||||
// same phone + postal code as the existing customer → duplicate candidate for the backoffice
|
||||
customer: { mode: "new", lastName: "Hausverwaltung Hafen", phone: "040 5551000", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
|
||||
site: { mode: "new", name: "Keller", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
|
||||
onSiteContact: { name: "Hausmeister Hafen", phone: "0171 5550000" },
|
||||
reason: "Heizung ausgefallen, Wohnungen ohne Warmwasser",
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r.workOrderId }, include: { customer: true, site: true } });
|
||||
ok(/^N-\d+$/.test(wo.number) && wo.isEmergency && wo.status === "in_progress", `Notdienstauftrag ${wo.number} in Arbeit`);
|
||||
ok(wo.customer.status === "provisional" && wo.customer.customerNumber === null && wo.site?.status === "provisional", "Kunde und Objekt vorläufig (ohne Kundennummer)");
|
||||
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "running" } })) === 1, "Einsatz läuft (Arbeitszeit gestartet)");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.created", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst angelegt“");
|
||||
await expectCode(() => getCustomer(A.ctx.outsider, wo.customerId), "not_found", "anderer Monteur sieht den vorläufigen Kunden nicht");
|
||||
await expectCode(() => getCustomer(B.ctx.admin, wo.customerId), "not_found", "Mandant B sieht den vorläufigen Kunden nicht");
|
||||
|
||||
section("2. Dokumentation und Abschluss ohne Unterschrift");
|
||||
await createNote(tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "Umwälzpumpe getauscht, Anlage wieder in Betrieb." }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Umwälzpumpe 25-40", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Pumpe defekt" }));
|
||||
const up = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: await jpegBytes("Pumpe"), name: "pumpe.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "after" }));
|
||||
await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id }));
|
||||
const report = (await createCompletionReport(tech, { workOrderId: wo.id })).report;
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Umwälzpumpe getauscht, Warmwasser wieder verfügbar." } });
|
||||
await expectCode(() => captureSignature(tech, { reportId: report.id, outcome: "customer_absent", confirmationText: "x" }), "invalid", "Kunde abwesend ohne Begründung → invalid");
|
||||
await captureSignature(tech, { reportId: report.id, outcome: "customer_absent", reason: "Nachts niemand erreichbar", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: report.id });
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Abschlussbericht eingereicht → Zur Prüfung");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.completed", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst abgeschlossen“ (In-App)");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "work_order.signature_missing", entityId: wo.id } })) === 1, "Backoffice erhält Hinweis „Unterschrift fehlt“");
|
||||
const mails = await prisma.mailLog.count({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } });
|
||||
ok(mails >= 1, `Pflicht-E-Mail an das Backoffice eingereiht/protokolliert (${mails})`);
|
||||
|
||||
section("3. Rollen und Mandantentrennung der Nachbearbeitung");
|
||||
await expectCode(() => listEmergencyReviews(tech), "forbidden", "Monteur: Prüfliste → forbidden");
|
||||
await expectCode(() => getEmergencyReview(A.ctx.lead, wo.id), "forbidden", "Teamleiter: Prüfung → forbidden");
|
||||
await expectCode(() => getEmergencyReview(B.ctx.backoffice, wo.id), "not_found", "Mandant B: Prüfung → not_found");
|
||||
await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: wo.id, targetCustomerId: A.customerId }), "not_found", "Mandant B: Kunde zuordnen → not_found");
|
||||
ok(!(await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).some((x) => x.id === wo.id), "Mandant B: Notdienst nicht in der Liste");
|
||||
|
||||
section("4. Backoffice prüft am nächsten Arbeitstag die Stammdaten");
|
||||
const listed = (await listEmergencyReviews(bo)).find((x) => x.id === wo.id);
|
||||
ok(!!listed && listed.progress.count === 0 && listed.progress.total === 5, "in „Zu prüfen“ mit 0 von 5 Prüfschritten");
|
||||
const review = await getEmergencyReview(bo, wo.id);
|
||||
ok(review.duplicates.some((d) => d.customerId === A.customerId), "bestehender Kunde als Dublettenkandidat vorgeschlagen");
|
||||
await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung mit vorläufigen Stammdaten → blocked");
|
||||
const assigned = await assignEmergencyToCustomer(bo, { workOrderId: wo.id, targetCustomerId: A.customerId });
|
||||
const provisional = await prisma.customer.findUniqueOrThrow({ where: { id: assigned.sourceId } });
|
||||
const afterAssign = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id }, include: { site: true } });
|
||||
ok(afterAssign.customerId === A.customerId && provisional.status === "merged" && provisional.mergedIntoId === A.customerId, "Auftrag dem bestehenden Kunden zugeordnet, vorläufiger Kunde zusammengeführt");
|
||||
ok(afterAssign.site?.customerId === A.customerId && afterAssign.version > wo.version, "Einsatzort mit umgehängt, Auftragsversion erhöht (Offline-Schutz)");
|
||||
await correctEmergencySite(bo, { workOrderId: wo.id, patch: { name: "Heizungskeller Hafenstraße 12", accessNotes: "Hausmeister öffnet" }, confirm: true });
|
||||
ok((await prisma.site.findUniqueOrThrow({ where: { id: afterAssign.siteId! } })).status === "active", "Objekt korrigiert und bestätigt");
|
||||
const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } });
|
||||
await completeEmergencyOrderData(bo, { workOrderId: wo.id, title: "Notdienst: Ausfall Heizung", orderTypeId: notdienst.id, billingType: "time_material" });
|
||||
await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung ohne freigegebenen Bericht → blocked");
|
||||
await approveReport(A.ctx.lead, { reportId: report.id }, noPdf);
|
||||
await approveReport(bo, { reportId: report.id }, noPdf);
|
||||
ok((await getEmergencyReview(bo, wo.id)).progress.count === 4, "4 von 5 Prüfschritten erledigt");
|
||||
|
||||
section("5. Auftrag wird zur Abrechnung freigegeben");
|
||||
const released = await releaseEmergencyForBilling(bo, { workOrderId: wo.id });
|
||||
ok(released.status === "released_for_billing", "Notdienst zur Abrechnung freigegeben");
|
||||
ok((await getEmergencyReview(bo, wo.id)).progress.count === 5, "5 von 5 Prüfschritten erledigt");
|
||||
ok(!(await listEmergencyReviews(bo)).some((x) => x.id === wo.id) && (await listEmergencyReviews(bo, { filter: "all" })).some((x) => x.id === wo.id), "aus „Zu prüfen“ entfernt, unter „Alle“ sichtbar");
|
||||
ok((await codeOf(markBilled(tech, { workOrderId: wo.id }))) === "forbidden", "Monteur darf nicht abrechnen");
|
||||
ok((await markBilled(bo, { workOrderId: wo.id })).status === "billed", "abgerechnet");
|
||||
const audits = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: wo.id } });
|
||||
ok(audits >= 4, `Prüfschritte auditiert (${audits})`);
|
||||
|
||||
const hist = await getSiteHistory(bo, afterAssign.siteId!);
|
||||
const entry = hist.items.find((i) => i.workOrderId === wo.id);
|
||||
ok(!!entry && entry.isEmergency && entry.approvedReports.length === 1 && !entry.signed, "Objekt-Historie: Notdienst mit freigegebenem Bericht, ohne Unterschrift");
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// L10a E2E §43.3 „Fehlende Pflichtfotos" und „Fehlende Unterschrift".
|
||||
//
|
||||
// Pflichtfotos: Pflichtfoto + Checklistenpunkt „mit Foto" blockieren technisch abschließen,
|
||||
// Abschlussbericht und Sync-Op mit strukturierter Blocker-Liste; Blocker verschwinden erst mit den
|
||||
// passenden Fotos. Unterschrift: ohne Unterschrift → „Unterschrift ausstehend“ (kein Weg zur Prüfung
|
||||
// oder Abrechnung), Ablehnung/Abwesenheit nur mit Grund (→ Prüfung + Hinweis ans Backoffice),
|
||||
// „später“ + Nachreichen, „nicht erforderlich“ nur ohne Pflicht, erfasste Unterschrift unveränderlich.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-guards.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { CompletionBlocker } from "../src/lib/work-orders/status";
|
||||
import { ServiceError } from "../src/server/services/context";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { getCompletionBlockers } from "../src/server/services/work-orders/completion";
|
||||
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession, startSession } from "../src/server/services/field/sessions";
|
||||
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { storeFile } from "../src/server/services/documents/store";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { contentOf } from "../src/server/services/reports/common";
|
||||
import { checklistTogglePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-guard-a";
|
||||
const SLUG_B = "zz-q-e2e-guard-b";
|
||||
|
||||
async function blockersOf(fn: () => Promise<unknown>): Promise<CompletionBlocker[] | string> {
|
||||
try {
|
||||
await fn();
|
||||
return "ok";
|
||||
} catch (err) {
|
||||
return err instanceof ServiceError && err.code === "blocked" ? (err.details as CompletionBlocker[]) : String((err as { code?: string }).code ?? (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Order assigned to tech, worked and ended; ready for the completion step. */
|
||||
async function workedOrder(A: TenantFixture, input: { signatureRequired: boolean; title: string; photoRequirements?: { key: string; label: string }[]; checklistItems?: { label: string; required: boolean; requiresPhoto?: boolean }[] }) {
|
||||
const wo = await createWorkOrder(A.ctx.backoffice, { customerId: A.customerId, siteId: A.siteId, applyTemplate: false, ...input });
|
||||
await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() }));
|
||||
await endSession(A.ctx.tech, sessionControlPayload.parse({ workOrderId: wo.id }));
|
||||
return wo.id;
|
||||
}
|
||||
|
||||
async function readyReport(A: TenantFixture, workOrderId: string) {
|
||||
const { report } = await createCompletionReport(A.ctx.tech, { workOrderId });
|
||||
await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Arbeiten ausgeführt." } });
|
||||
return report.id;
|
||||
}
|
||||
|
||||
const status = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).status;
|
||||
|
||||
runSuite("E2E Pflichtfotos und Unterschrift", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const tech = A.ctx.tech;
|
||||
const jpeg = await jpegBytes("Nachweis");
|
||||
|
||||
// ================= Pflichtfotos =================
|
||||
section("Fehlende Pflichtfotos blockieren den Abschluss");
|
||||
const w1 = await workedOrder(A, {
|
||||
title: "Wärmepumpe montieren",
|
||||
signatureRequired: true,
|
||||
photoRequirements: [{ key: "typenschild", label: "Typenschild" }],
|
||||
checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }],
|
||||
});
|
||||
const b1 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w1, to: "technically_completed" }));
|
||||
ok(Array.isArray(b1) && b1.some((b) => b.kind === "photo_requirement" && b.label === "Typenschild") && b1.some((b) => b.kind === "checklist_item"), "technisch abschließen → blocked mit Pflichtfoto + Checklistenpunkt");
|
||||
const b2 = await blockersOf(() => createCompletionReport(tech, { workOrderId: w1 }));
|
||||
ok(Array.isArray(b2) && b2.length === 2, "Abschlussbericht → blocked mit derselben Liste");
|
||||
const listed = await getCompletionBlockers(tech, w1);
|
||||
ok(listed.length === 2, "Blocker-Liste für die UI abrufbar");
|
||||
await expectCode(() => getCompletionBlockers(B.ctx.tech, w1), "not_found", "Mandant B: Blocker-Liste → not_found");
|
||||
await expectCode(() => getCompletionBlockers(A.ctx.outsider, w1), "not_found", "Monteur ohne Zuweisung: Blocker-Liste → not_found");
|
||||
const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w1 }, include: { checklistItems: true, photoRequirements: true } });
|
||||
const viaSync = await applyOperations(tech, { deviceId: "g", operations: [{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: wo1.version, payload: { workOrderId: w1, to: "technically_completed" }, clientCreatedAt: new Date().toISOString() }] });
|
||||
ok(viaSync.results[0].status === "rejected" && viaSync.results[0].errorCode === "blocked", "auch offline (Sync) → rejected blocked");
|
||||
|
||||
await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: w1, itemId: wo1.checklistItems[0].id, checked: true }));
|
||||
const b3 = await getCompletionBlockers(tech, w1);
|
||||
ok(b3.some((b) => b.kind === "checklist_item"), "abgehakt ohne Foto → Checklistenpunkt „mit Foto“ bleibt offen");
|
||||
const notAnImage = Buffer.from("%PDF-1.4 kein Foto");
|
||||
await expectCode(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: notAnImage, name: "fake.jpg", type: "image/jpeg" }), "invalid", "Nicht-Bild als Foto → invalid");
|
||||
const p1 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "funktion.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p1.documentId, checklistItemId: wo1.checklistItems[0].id, phase: "after" }));
|
||||
const b4 = await getCompletionBlockers(tech, w1);
|
||||
ok(b4.length === 1 && b4[0].kind === "photo_requirement", "Foto am Checklistenpunkt → nur noch Pflichtfoto offen");
|
||||
const p2 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" });
|
||||
await expectCode(() => attachPhoto(A.ctx.tech2, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id })), ["not_found", "forbidden"], "Foto eines anderen Nutzers kann nicht angehängt werden");
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id, phase: "after" }));
|
||||
ok((await getCompletionBlockers(tech, w1)).length === 0, "alle Pflichtfotos vorhanden → keine Blocker");
|
||||
ok((await codeOf(createCompletionReport(tech, { workOrderId: w1 }))) === "ok", "Abschlussbericht jetzt möglich");
|
||||
|
||||
// ================= Unterschrift =================
|
||||
section("Fehlende Unterschrift: ohne Erfassung bleibt der Auftrag „Unterschrift ausstehend“");
|
||||
const w2 = await workedOrder(A, { title: "Ohne Unterschrift", signatureRequired: true });
|
||||
const r2 = await readyReport(A, w2);
|
||||
await submitReport(tech, { reportId: r2 });
|
||||
ok((await status(w2)) === "signature_pending", "Abschluss ohne Unterschrift → signature_pending");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w2, userId: A.users.backoffice.id } })) >= 1, "Backoffice erhält „Unterschrift fehlt“");
|
||||
const b5 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w2, to: "in_review" }));
|
||||
ok(Array.isArray(b5) && b5.some((b) => b.kind === "missing_field" && b.field === "signature"), "zur Prüfung ohne Unterschrift → blocked (signature)");
|
||||
await expectCode(() => releaseForBilling(A.ctx.backoffice, { workOrderId: w2 }), "invalid", "Abrechnung aus „Unterschrift ausstehend“ nicht möglich");
|
||||
const img2 = await storeFile(tech, { bytes: await pngBytes(), fileName: "u.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: w2 } });
|
||||
await captureSignature(tech, { reportId: r2, outcome: "signed", signerName: "Herr Nachtrag", imageDocumentId: img2.id, confirmationText: "Bestätigt" });
|
||||
ok((await status(w2)) === "in_review", "nachgereichte Unterschrift → automatisch zur Prüfung");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r2, outcome: "refused", reason: "doch nicht", confirmationText: "x" }), "conflict", "erfasste Unterschrift kann nicht überschrieben werden");
|
||||
|
||||
section("Unterschrift verweigert / Kunde abwesend: nur mit Grund");
|
||||
const w3 = await workedOrder(A, { title: "Kunde verweigert", signatureRequired: true });
|
||||
const r3 = await readyReport(A, w3);
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "refused", confirmationText: "x" }), "invalid", "Verweigerung ohne Grund → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "customer_absent", reason: " ", confirmationText: "x" }), "invalid", "Abwesenheit mit leerem Grund → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Ohne Bild", confirmationText: "x" }), "invalid", "Unterschrift ohne Bild → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Fremdes Bild", imageDocumentId: img2.id, confirmationText: "x" }), "invalid", "Unterschriftsbild eines anderen Auftrags → invalid");
|
||||
await captureSignature(tech, { reportId: r3, outcome: "refused", reason: "Kunde mit Ausführung nicht einverstanden", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r3 });
|
||||
const rep3 = await prisma.report.findUniqueOrThrow({ where: { id: r3 } });
|
||||
ok((await status(w3)) === "in_review", "Verweigerung mit Grund → Zur Prüfung");
|
||||
ok(contentOf(rep3).signature?.outcome === "refused" && contentOf(rep3).signature?.reason === "Kunde mit Ausführung nicht einverstanden", "Grund im Berichts-Snapshot");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w3 } })) >= 1, "Backoffice wird über die fehlende Unterschrift informiert");
|
||||
await expectCode(() => captureSignature(B.ctx.tech, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen");
|
||||
await expectCode(() => captureSignature(A.ctx.outsider, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Monteur ohne Zuweisung kann keine Unterschrift erfassen");
|
||||
|
||||
section("„Später“ und „nicht erforderlich“");
|
||||
const w4 = await workedOrder(A, { title: "Später unterschreiben", signatureRequired: true });
|
||||
const r4 = await readyReport(A, w4);
|
||||
await expectCode(() => captureSignature(tech, { reportId: r4, outcome: "not_required", confirmationText: "x" }), "forbidden", "Monteur: „nicht erforderlich“ bei Pflicht-Unterschrift → forbidden");
|
||||
await captureSignature(tech, { reportId: r4, outcome: "later", reason: "Ansprechpartner erst morgen da", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r4 });
|
||||
ok((await status(w4)) === "signature_pending", "„später“ → Unterschrift ausstehend");
|
||||
await captureSignature(tech, { reportId: r4, outcome: "customer_absent", reason: "Auch am Folgetag nicht erreichbar", confirmationText: "Bestätigt" });
|
||||
ok((await status(w4)) === "in_review", "Nachtrag „Kunde abwesend“ mit Grund → Zur Prüfung");
|
||||
|
||||
const w5 = await workedOrder(A, { title: "Besichtigung ohne Unterschrift", signatureRequired: false });
|
||||
const r5 = await readyReport(A, w5);
|
||||
await captureSignature(tech, { reportId: r5, outcome: "not_required", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r5 });
|
||||
ok((await status(w5)) === "in_review", "Auftrag ohne Unterschriftspflicht: „nicht erforderlich“ → Zur Prüfung");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// L10a E2E §43.3 „Dublettenprüfung" beim Import (Spec §7.3, US-003) — Kandidaten werden gefunden
|
||||
// (Kundennummer, Firmenname, Adresse Straße/Str., E-Mail unabhängig von Groß-/Kleinschreibung,
|
||||
// Telefon unabhängig vom Format), aber NIE automatisch zugeordnet oder zusammengeführt; die
|
||||
// Entscheidung (bestehend / neu) trifft das Backoffice und wird protokolliert. Manuelle Anlage
|
||||
// zeigt dieselben Kandidaten. Mandant B findet nichts von A, Monteure haben keinen Zugriff.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-import-duplicates.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { processImport } from "../src/server/services/imports/process";
|
||||
import { confirmImport } from "../src/server/services/imports/confirm";
|
||||
import { getImportDetail } from "../src/server/services/imports/queries";
|
||||
import { extractionToForm } from "../src/lib/imports/review";
|
||||
import { createCustomer } from "../src/server/services/customers/customers";
|
||||
import { findDuplicateCustomers } from "../src/server/services/customers/duplicates";
|
||||
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-dup-a";
|
||||
const SLUG_B = "zz-q-e2e-dup-b";
|
||||
|
||||
type Candidate = { customerId: string; score: number; reasons: string[] };
|
||||
|
||||
async function importWith(ctx: ServiceCtx, extraction: Partial<WorkOrderExtraction>) {
|
||||
const pdf = buildPdf([[{ text: "Auftrag" }]]);
|
||||
const job = await createImport(ctx, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined });
|
||||
const processed = await processImport(ctx, job.id, { provider: new FakeExtractionProvider({ extraction }), loadBytes: async () => pdf });
|
||||
return { job, candidates: (processed.duplicateCandidates ?? []) as Candidate[] };
|
||||
}
|
||||
|
||||
runSuite("E2E Dublettenprüfung beim Import", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
|
||||
const firm = await prisma.customer.create({
|
||||
data: { tenantId: A.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "+49 40 5551234" },
|
||||
});
|
||||
const person = await prisma.customer.create({
|
||||
data: { tenantId: A.tenantId, customerNumber: "K-50002", firstName: "Erika", lastName: "Beispiel", email: "erika@beispiel.example", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg" },
|
||||
});
|
||||
const foreign = await prisma.customer.create({ data: { tenantId: B.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", postalCode: "20457", phone: "040 5551234" } });
|
||||
const firmBefore = await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } });
|
||||
|
||||
section("1. Firmenkunde: Nummer, Name, Adresse (Str.), Telefon in anderem Format");
|
||||
const i1 = await importWith(bo, {
|
||||
customerNumber: { value: "K-50001", confidence: 0.9 },
|
||||
companyName: { value: "Musterbau Haustechnik GmbH", confidence: 0.95 },
|
||||
customerAddress: { value: { street: "Hafenstr.", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
phone: { value: "040 / 555 12 34", confidence: 0.9 },
|
||||
title: { value: "Wartung Lüftung", confidence: 0.9 },
|
||||
});
|
||||
const top = i1.candidates[0];
|
||||
ok(top?.customerId === firm.id, "bestehender Firmenkunde ist bester Kandidat");
|
||||
ok(!!top && ["customer_number", "company_name", "address", "phone"].every((r) => top.reasons.includes(r)), `Gründe: ${top?.reasons.join(", ")}`);
|
||||
ok(!i1.candidates.some((c) => c.customerId === foreign.id), "Kandidaten nie aus Mandant B");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "keine automatische Zuordnung, kein Auftrag ohne Bestätigung");
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } })).updatedAt.getTime() === firmBefore.updatedAt.getTime(), "bestehender Kunde unverändert");
|
||||
|
||||
section("2. Privatkunde: E-Mail in anderer Schreibweise");
|
||||
const i2 = await importWith(bo, {
|
||||
customerFirstName: { value: "Erika", confidence: 0.9 },
|
||||
customerLastName: { value: "Beispiel", confidence: 0.9 },
|
||||
email: { value: "ERIKA@Beispiel.example", confidence: 0.9 },
|
||||
title: { value: "Reparatur Mischbatterie", confidence: 0.9 },
|
||||
});
|
||||
ok(i2.candidates.some((c) => c.customerId === person.id && c.reasons.includes("email")), "Privatkunde über E-Mail gefunden");
|
||||
|
||||
section("3. Neukunde ohne Überschneidung");
|
||||
const i3 = await importWith(bo, {
|
||||
companyName: { value: "Kieler Förde Wohnbau AG", confidence: 0.9 },
|
||||
customerAddress: { value: { street: "Holtenauer Straße", houseNumber: "200", postalCode: "24105", city: "Kiel" }, confidence: 0.9 },
|
||||
title: { value: "Neubau Heizzentrale", confidence: 0.9 },
|
||||
});
|
||||
ok(i3.candidates.length === 0, "kein Kandidat bei Neukunde");
|
||||
|
||||
section("4. Entscheidung des Backoffice");
|
||||
const countBefore = await prisma.customer.count({ where: { tenantId: A.tenantId } });
|
||||
const d2 = await getImportDetail(bo, i2.job.id);
|
||||
const c2 = await confirmImport(bo, i2.job.id, extractionToForm(d2.extraction.fields, { customerId: person.id }));
|
||||
ok(c2.customerId === person.id && (await prisma.customer.count({ where: { tenantId: A.tenantId } })) === countBefore, "Kandidat übernommen → kein neuer Kunde");
|
||||
const d1 = await getImportDetail(bo, i1.job.id);
|
||||
const f1 = extractionToForm(d1.extraction.fields);
|
||||
ok(f1.customerMode === "new", "Formular schlägt ohne Entscheidung keinen bestehenden Kunden automatisch vor");
|
||||
await expectCode(() => confirmImport(bo, i1.job.id, f1), "conflict", "„neu anlegen“ mit vergebener Kundennummer → conflict, keine Teilanlage");
|
||||
f1.customer!.customerNumber = "";
|
||||
const c1 = await confirmImport(bo, i1.job.id, f1);
|
||||
const newFirm = await prisma.customer.findUniqueOrThrow({ where: { id: c1.customerId } });
|
||||
ok(newFirm.id !== firm.id && /^K-\d+$/.test(newFirm.customerNumber ?? ""), `bewusst neu angelegt mit eigener Nummer (${newFirm.customerNumber})`);
|
||||
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, status: "merged" } })) === 0, "keine automatische Zusammenführung");
|
||||
const job1 = await prisma.importJob.findUniqueOrThrow({ where: { id: i1.job.id } });
|
||||
ok((job1.corrections as { decisions?: { customerMode?: string } }).decisions?.customerMode === "new", "Entscheidung „neu“ am Import protokolliert");
|
||||
const d3 = await getImportDetail(bo, i3.job.id);
|
||||
const c3 = await confirmImport(bo, i3.job.id, extractionToForm(d3.extraction.fields));
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: c3.customerId } })).companyName === "Kieler Förde Wohnbau AG", "Neukunde angelegt");
|
||||
|
||||
section("5. Manuelle Anlage und Zusammenführung nur mit Bestätigung");
|
||||
const dupErr = await codeOf(createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }));
|
||||
ok(dupErr === "conflict", "manuelle Anlage → „Mögliche Dublette“ (conflict)");
|
||||
const manual = await createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }, { acknowledgeDuplicates: true });
|
||||
ok(manual.status === "active", "„Trotzdem neu anlegen“ möglich");
|
||||
await expectCode(() => mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: false } as never), "invalid", "Zusammenführen ohne Bestätigung → invalid");
|
||||
await expectCode(() => mergeCustomers(A.ctx.tech, { sourceId: manual.id, targetId: firm.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen");
|
||||
await expectCode(() => mergeCustomers(B.ctx.backoffice, { sourceId: foreign.id, targetId: firm.id, confirm: true }), "not_found", "Mandant B kann nicht mit Kunden von A zusammenführen");
|
||||
const merged = await mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: true });
|
||||
ok(!!merged && (await prisma.customer.findUniqueOrThrow({ where: { id: manual.id } })).mergedIntoId === firm.id, "mit Bestätigung zusammengeführt");
|
||||
|
||||
section("6. Mandantentrennung und Rollen");
|
||||
const fromB = await findDuplicateCustomers(B.ctx.backoffice, { customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", email: "erika@beispiel.example" });
|
||||
ok(fromB.every((c) => c.customerId === foreign.id), "Dublettenprüfung in Mandant B findet nur B-Kunden");
|
||||
ok((await findDuplicateCustomers(A.ctx.outsider, { customerNumber: "K-50001" })).length === 0, "Monteur ohne Aufträge findet keine Kunden");
|
||||
await expectCode(() => getImportDetail(A.ctx.tech, i3.job.id), "forbidden", "Monteur: Prüfmaske → forbidden");
|
||||
await expectCode(() => confirmImport(B.ctx.backoffice, i3.job.id, extractionToForm(d3.extraction.fields)), "not_found", "Mandant B: Import von A bestätigen → not_found");
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// L10a E2E §43.3 „Mehrtägiger Auftrag mit Tagesbericht" — zwei Einsatztage, zwei Tagesberichte, Abschluss.
|
||||
//
|
||||
// Tag 1 (vorgestern) und Tag 2 (gestern) werden mit echten Zeitstempeln erfasst (Session-Segmente,
|
||||
// Fotos mit Aufnahmezeit; Notizen/Material werden als „am Einsatztag erfasst" zurückdatiert, weil
|
||||
// die Services den Erfassungszeitpunkt selbst setzen). Nachweise: Tagesbericht enthält nur die Daten
|
||||
// seines Tages, Auftrag bleibt offen (daily_report_created → in_progress am nächsten Tag), ein
|
||||
// Tagesbericht je Tag, Abschlussbericht fasst beide Tage zusammen, Freigabe + Abrechnung,
|
||||
// Mandantentrennung und Scope.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-multiday-order.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession, startSession } from "../src/server/services/field/sessions";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { storeFile } from "../src/server/services/documents/store";
|
||||
import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { contentOf } from "../src/server/services/reports/common";
|
||||
import { listReports } from "../src/server/services/reports/queries";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops";
|
||||
import { berlinAt, createTenant, dayKey, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-multi-a";
|
||||
const SLUG_B = "zz-q-e2e-multi-b";
|
||||
const noPdf = { dispatchPdf: async () => undefined };
|
||||
|
||||
runSuite("E2E mehrtägiger Auftrag", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const DAY1 = dayKey(-2);
|
||||
const DAY2 = dayKey(-1);
|
||||
const jpeg = await jpegBytes("Baustelle");
|
||||
|
||||
const wo = await createWorkOrder(bo, {
|
||||
title: "Heizungsverteilung erneuern (2 Tage)",
|
||||
customerId: A.customerId,
|
||||
siteId: A.siteId,
|
||||
plannedStart: berlinAt(-2, 7),
|
||||
plannedEnd: berlinAt(-1, 16),
|
||||
signatureRequired: true,
|
||||
applyTemplate: false,
|
||||
checklistItems: [{ label: "Druckprobe durchgeführt", required: true }],
|
||||
materials: [{ name: "Kupferrohr 22 mm", articleNumber: "CU-22", plannedQuantity: 20, unit: "m" }],
|
||||
});
|
||||
await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id, A.users.tech2.id] });
|
||||
const plan = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: wo.id } });
|
||||
|
||||
/** Note/material recorded on a past field day (the services stamp "now"; the device was on site then). */
|
||||
async function noteOn(ctx: ServiceCtx, when: Date, text: string) {
|
||||
const { noteId } = await createNote(ctx, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text }));
|
||||
await prisma.activityNote.update({ where: { id: noteId }, data: { createdAt: when } });
|
||||
}
|
||||
async function photoOn(ctx: ServiceCtx, when: Date, comment: string) {
|
||||
const up = await storeFieldUpload(ctx, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(ctx, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "during", comment, takenAt: when.toISOString() }));
|
||||
}
|
||||
|
||||
// ---------- Tag 1 ----------
|
||||
section(`Tag 1 (${DAY1}): Einsatz und Tagesbericht`);
|
||||
const tech = A.ctx.tech;
|
||||
await startSession(tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-2, 7).toISOString() }));
|
||||
await noteOn(tech, berlinAt(-2, 11), "Tag 1: alte Verteilung demontiert");
|
||||
const u1 = await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, materialPlanId: plan.id, quantity: 8, unit: "m", usageStatus: "partially_used", deviationReason: "Rest folgt an Tag 2" }));
|
||||
await prisma.materialUsage.update({ where: { id: u1.usageId }, data: { createdAt: berlinAt(-2, 12) } });
|
||||
await photoOn(tech, berlinAt(-2, 13), "Tag 1 Konsolen");
|
||||
await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-2, 15).toISOString() }));
|
||||
|
||||
await expectCode(() => createDailyReport(B.ctx.tech, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found");
|
||||
await expectCode(() => createDailyReport(A.ctx.outsider, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht → not_found");
|
||||
const d1 = (await createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 })).report;
|
||||
const c1 = contentOf(d1);
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "daily_report_created", "Auftrag → Tagesbericht erstellt (bleibt offen)");
|
||||
ok(c1.time.totalMinutes === 480, `Tagesbericht 1: nur Arbeitszeit von Tag 1 (${c1.time.totalMinutes} min)`);
|
||||
ok(c1.photos.length === 1 && c1.texts.workPerformed.includes("Tag 1") && !c1.texts.workPerformed.includes("Tag 2"), "Tagesbericht 1: Fotos und Tätigkeiten nur von Tag 1");
|
||||
ok(c1.materials.used.length === 1 && c1.materials.used[0].deviationReason === "Rest folgt an Tag 2", "Tagesbericht 1: Material von Tag 1 mit Abweichungsgrund");
|
||||
await submitReport(tech, { reportId: d1.id });
|
||||
await approveReport(A.ctx.lead, { reportId: d1.id }, noPdf);
|
||||
const d1Approved = await approveReport(bo, { reportId: d1.id }, noPdf);
|
||||
ok(d1Approved.status === "approved", "Tagesbericht 1 eingereicht, vom Teamleiter geprüft und vom Backoffice freigegeben");
|
||||
await expectCode(() => createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 }), "conflict", "zweiter Tagesbericht für denselben Tag → conflict");
|
||||
|
||||
// ---------- Tag 2 ----------
|
||||
section(`Tag 2 (${DAY2}): zweiter Monteur, zweiter Tagesbericht`);
|
||||
const tech2 = A.ctx.tech2;
|
||||
const s2 = await startSession(tech2, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-1, 7).toISOString() }));
|
||||
ok(s2.workOrderStatus === "in_progress", "Arbeitsbeginn am Folgetag → Auftrag wieder in Arbeit");
|
||||
await noteOn(tech2, berlinAt(-1, 10), "Tag 2: Heizkreise angeschlossen, Druckprobe bestanden");
|
||||
const u2 = await upsertMaterialUsage(tech2, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Kugelhahn 22 mm", quantity: 4, unit: "Stk", usageStatus: "additional", deviationReason: "Absperrung je Heizkreis" }));
|
||||
await prisma.materialUsage.update({ where: { id: u2.usageId }, data: { createdAt: berlinAt(-1, 11) } });
|
||||
await photoOn(tech2, berlinAt(-1, 12), "Tag 2 fertig");
|
||||
const item = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: wo.id } });
|
||||
await toggleChecklistItem(tech2, checklistTogglePayload.parse({ workOrderId: wo.id, itemId: item.id, checked: true }));
|
||||
await endSession(tech2, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-1, 14).toISOString() }));
|
||||
const d2 = (await createDailyReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report;
|
||||
const c2 = contentOf(d2);
|
||||
ok(c2.time.totalMinutes === 420 && c2.photos.length === 1, `Tagesbericht 2: nur Zeiten/Fotos von Tag 2 (${c2.time.totalMinutes} min)`);
|
||||
ok(c2.texts.workPerformed.includes("Tag 2") && !c2.texts.workPerformed.includes("Tag 1"), "Tagesbericht 2: nur Tätigkeiten von Tag 2");
|
||||
ok(c2.materials.additional.length === 1 && c2.materials.used.length === 0, "Tagesbericht 2: nur Material von Tag 2");
|
||||
ok(c2.reportNumber !== c1.reportNumber && d2.lineageId !== d1.lineageId, `eigene Berichtsnummer je Tag (${c1.reportNumber}, ${c2.reportNumber})`);
|
||||
await submitReport(tech2, { reportId: d2.id });
|
||||
|
||||
// ---------- Abschluss ----------
|
||||
section("Abschlussbericht über beide Tage, Freigabe, Abrechnung");
|
||||
const completion = (await createCompletionReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report;
|
||||
const cc = contentOf(completion);
|
||||
ok(cc.workDates.join(",") === `${DAY1},${DAY2}` && cc.time.totalMinutes === 900, `Abschlussbericht: beide Einsatztage, Gesamtzeit ${cc.time.totalMinutes} min`);
|
||||
ok(cc.photos.length === 2 && cc.materials.used.length === 1 && cc.materials.additional.length === 1, "Abschlussbericht: alle Fotos und Materialien");
|
||||
await updateReportTexts(tech2, { reportId: completion.id, texts: { workPerformed: "Heizungsverteilung in zwei Tagen erneuert, Druckprobe bestanden." } });
|
||||
const img = await storeFile(tech2, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } });
|
||||
await captureSignature(tech2, { reportId: completion.id, outcome: "signed", signerName: "Frau Prüf", imageDocumentId: img.id, confirmationText: "Bestätigt." });
|
||||
await submitReport(tech2, { reportId: completion.id });
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Auftrag nach Abschluss „Zur Prüfung“");
|
||||
for (const r of [d2.id, completion.id]) {
|
||||
await approveReport(A.ctx.lead, { reportId: r }, noPdf);
|
||||
await approveReport(bo, { reportId: r }, noPdf);
|
||||
}
|
||||
const released = await releaseForBilling(bo, { workOrderId: wo.id });
|
||||
ok(released.status === "released_for_billing", "zur Abrechnung freigegeben");
|
||||
|
||||
const reports = await listReports(bo, { status: "all" });
|
||||
const mine = reports.items.filter((r) => r.workOrder.id === wo.id);
|
||||
ok(mine.filter((r) => r.type === "daily").length === 2 && mine.filter((r) => r.type === "completion").length === 1, "Berichtsliste: 2 Tagesberichte + 1 Abschlussbericht");
|
||||
ok((await listReports(B.ctx.backoffice, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Mandant B sieht keine Berichte von A");
|
||||
ok((await listReports(A.ctx.outsider, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Monteur ohne Zuweisung sieht keine Berichte des Auftrags");
|
||||
|
||||
const hist = await getSiteHistory(bo, A.siteId);
|
||||
const entry = hist.items.find((i) => i.workOrderId === wo.id);
|
||||
ok(!!entry && entry.approvedReports.length === 3 && entry.workDone.length === 2, "Objekt-Historie: 3 freigegebene Berichte, Tätigkeiten beider Tage");
|
||||
ok(!!entry && entry.date.getTime() === berlinAt(-2, 7).getTime(), "Objekt-Historie: Einsatzbeginn = erster Einsatztag");
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
// L10a E2E §43.3 „Offline-Erfassung und Synchronisation" — derselbe Pfad wie POST /api/v1/sync:
|
||||
// ein offline gesammelter Batch (12 Ops, 2 Tage alt) → alle applied; Wiederholung → duplicate;
|
||||
// Pflichtfoto fehlt → blocked; veraltete Statusänderung → conflict (nichts überschrieben, Event
|
||||
// sync.failed, Backoffice-Konfliktliste) → Backoffice übernimmt bzw. verwirft; ungültige/nicht
|
||||
// verfügbare Ops; fremde clientOpId; Mandantentrennung und Scope.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-offline-sync.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { SyncOperationInput } from "../src/lib/sync/envelope";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { getFieldBundle } from "../src/server/services/field/queries";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { updateWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts";
|
||||
import { createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-sync-a";
|
||||
const SLUG_B = "zz-q-e2e-sync-b";
|
||||
|
||||
function op(opType: SyncOperationInput["opType"], payload: Record<string, unknown>, extra: Partial<SyncOperationInput> = {}, at = new Date()): SyncOperationInput {
|
||||
return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: at.toISOString(), ...extra };
|
||||
}
|
||||
|
||||
runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
const created = await createWorkOrder(bo, {
|
||||
title: "Offline-Einsatz Tiefgarage",
|
||||
customerId: A.customerId,
|
||||
siteId: A.siteId,
|
||||
plannedStart: new Date(),
|
||||
applyTemplate: false,
|
||||
checklistItems: [{ label: "Absperrung gesetzt", required: true }],
|
||||
photoRequirements: [{ key: "fertige_montage", label: "Fertige Montage" }],
|
||||
materials: [{ name: "Brandschutzmanschette", plannedQuantity: 4, unit: "Stk" }],
|
||||
});
|
||||
await assignWorkOrder(bo, { workOrderId: created.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
|
||||
section("1. Vorab-Download und Offline-Erfassung");
|
||||
const bundle = await getFieldBundle(tech);
|
||||
const offline = bundle.orders.find((o) => o.id === created.id)!;
|
||||
ok(!!offline, "Auftrag im Offline-Bundle des Monteurs");
|
||||
const photoDoc = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: created.id, kind: "photo" }, { bytes: await jpegBytes("offline"), name: "offline.jpg", type: "image/jpeg" });
|
||||
const t0 = Date.now() - 2 * 86400_000;
|
||||
const at = (min: number) => new Date(t0 + min * 60_000);
|
||||
const ids = { travel: randomUUID(), note1: randomUUID(), note2: randomUUID(), extra: randomUUID(), photo: randomUUID() };
|
||||
const w = created.id;
|
||||
const batch: SyncOperationInput[] = [
|
||||
op("work_order.transition", { workOrderId: w, to: "accepted" }, { baseVersion: offline.version }, at(0)),
|
||||
op("session.start", { workOrderId: w, mode: "travel", clientId: ids.travel, offline: true, at: at(1).toISOString() }, {}, at(1)),
|
||||
op("session.start", { workOrderId: w, mode: "work", at: at(30).toISOString() }, {}, at(30)),
|
||||
op("note.create", { workOrderId: w, clientId: ids.note1, kind: "work_done", text: "Manschetten gesetzt (offline)" }, {}, at(40)),
|
||||
op("checklist.toggle", { workOrderId: w, itemId: offline.checklistItems[0].id, checked: true }, {}, at(41)),
|
||||
op("material.upsert", { workOrderId: w, materialPlanId: offline.materialPlans[0].id, quantity: 4, unit: "Stk", usageStatus: "fully_used" }, {}, at(42)),
|
||||
op("material.upsert", { workOrderId: w, clientId: ids.extra, name: "Brandschutzkitt", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Fuge zu breit" }, {}, at(43)),
|
||||
op("photo.attach", { workOrderId: w, clientId: ids.photo, documentId: photoDoc.documentId, phase: "during" }, {}, at(44)),
|
||||
op("session.pause", { workOrderId: w, at: at(60).toISOString() }, {}, at(60)),
|
||||
op("session.resume", { workOrderId: w, at: at(75).toISOString() }, {}, at(75)),
|
||||
op("note.create", { workOrderId: w, clientId: ids.note2, kind: "general", text: "Keine Auffälligkeiten" }, {}, at(80)),
|
||||
op("session.end", { workOrderId: w, at: at(120).toISOString() }, {}, at(120)),
|
||||
];
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 0, "offline: noch nichts auf dem Server");
|
||||
|
||||
section("2. Verbindung da: ein Batch");
|
||||
const res = await applyOperations(tech, { deviceId: "e2e-device", operations: batch });
|
||||
const statuses = res.results.map((x) => x.status);
|
||||
ok(statuses.every((s) => s === "applied"), `alle 12 Ops applied (${statuses.join(",")})`);
|
||||
if (!statuses.every((s) => s === "applied")) console.log(res.results.filter((x) => x.status !== "applied"));
|
||||
ok(!!res.results[1].idMap?.[ids.travel] && !!res.results[3].idMap?.[ids.note1] && !!res.results[7].idMap?.[ids.photo], "idMap bildet Client-IDs auf Server-IDs ab");
|
||||
ok(typeof res.results[0].entityVersion === "number" && res.results[0].entityVersion! > offline.version, "Statusänderung liefert neue Auftragsversion");
|
||||
const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
ok(wo1.status === "in_progress", `Auftragsstatus nach Sync: in Arbeit (${wo1.status})`);
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2 && (await prisma.photo.count({ where: { workOrderId: w } })) === 1, "Notizen, Material und Foto angelegt");
|
||||
const session = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: w }, include: { entries: { orderBy: { startedAt: "asc" } } } });
|
||||
ok(session.status === "ended" && session.startedOffline && session.entries.map((e) => e.type).join(",") === "travel,work,break,work", "Session offline gestartet, Segmente Anfahrt/Arbeit/Pause/Arbeit");
|
||||
ok(session.startedAt.getTime() === at(1).getTime() && session.endedAt?.getTime() === at(120).getTime(), "Zeitstempel des Geräts übernommen (2 Tage alt)");
|
||||
const stored = await prisma.syncOperation.findMany({ where: { tenantId: A.tenantId, clientOpId: { in: batch.map((b) => b.clientOpId) } } });
|
||||
ok(stored.length === 12 && stored.every((s) => s.status === "applied" && s.userId === A.users.tech.id), "12 SyncOperations protokolliert");
|
||||
|
||||
section("3. Wiederholung (Verbindungsabbruch nach dem Senden)");
|
||||
const replay = await applyOperations(tech, { deviceId: "e2e-device", operations: batch });
|
||||
ok(replay.results.every((x) => x.status === "duplicate"), "gleicher Batch erneut → 12× duplicate");
|
||||
ok(replay.results[3].idMap?.[ids.note1] === res.results[3].idMap?.[ids.note1], "duplicate liefert gespeicherte idMap");
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2, "keine Doppelanlagen");
|
||||
const stolen = await applyOperations(A.ctx.tech2, { deviceId: "other", operations: [batch[3]] });
|
||||
ok(stolen.results[0].status === "rejected" && stolen.results[0].errorCode === "invalid", "fremde clientOpId eines anderen Nutzers → rejected");
|
||||
|
||||
section("4. Pflichtfoto fehlt → Abschluss offline abgelehnt");
|
||||
const blocked = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "technically_completed" }, { baseVersion: wo1.version })] });
|
||||
ok(blocked.results[0].status === "rejected" && blocked.results[0].errorCode === "blocked" && /photo_requirement/.test(blocked.results[0].message ?? ""), "technisch abschließen ohne Pflichtfoto → rejected blocked (Blocker-Liste)");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "in_progress", "Status unverändert");
|
||||
|
||||
section("5. Konflikt: Büro ändert den Auftrag während der Monteur offline ist");
|
||||
const stale = (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).version;
|
||||
await updateWorkOrder(bo, w, { technicianNotes: "Bitte zusätzlich Revisionsklappe prüfen" });
|
||||
const conflictBatch = [
|
||||
op("work_order.transition", { workOrderId: w, to: "waiting_material" }, { baseVersion: stale }),
|
||||
op("note.create", { workOrderId: w, clientId: randomUUID(), kind: "problem", text: "Material fehlt" }),
|
||||
];
|
||||
const conflict = await applyOperations(tech, { deviceId: "e2e-device", operations: conflictBatch });
|
||||
ok(conflict.results[0].status === "conflict" && conflict.results[0].errorCode === "conflict", "veraltete Statusänderung → conflict");
|
||||
ok(conflict.results[1].status === "applied", "unabhängige Notiz trotzdem applied");
|
||||
const wo2 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
ok(wo2.status === "in_progress" && wo2.technicianNotes === "Bitte zusätzlich Revisionsklappe prüfen", "nichts überschrieben (Status + Büroänderung erhalten)");
|
||||
const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: conflictBatch[0].clientOpId } });
|
||||
ok(conflictOp.status === "conflict" && conflictOp.baseVersion === stale, "Konflikt mit Basisversion gespeichert");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.tech.id } })) >= 1, "Monteur wird über den Konflikt informiert");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.backoffice.id } })) >= 1, "Backoffice wird über den Konflikt informiert");
|
||||
const list = await listSyncConflicts(bo);
|
||||
ok(list.some((c) => c.id === conflictOp.id && c.workOrder?.id === w && c.userName === `tech ${SLUG_A}`), "Konflikt in der Backoffice-Liste (Auftrag, Gerätenutzer)");
|
||||
await expectCode(() => listSyncConflicts(tech), "forbidden", "Monteur: Konfliktliste → forbidden");
|
||||
|
||||
section("6. Backoffice löst Konflikte");
|
||||
await expectCode(() => applySyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht übernehmen");
|
||||
await expectCode(() => discardSyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht verwerfen");
|
||||
ok(!(await listSyncConflicts(B.ctx.backoffice)).some((c) => c.id === conflictOp.id), "Mandant B sieht den Konflikt nicht");
|
||||
await applySyncConflict(bo, conflictOp.id);
|
||||
const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } });
|
||||
ok(wo3.status === "waiting_material" && resolved.status === "applied" && resolved.resolvedById === A.users.backoffice.id, "Übernehmen: Änderung als Gerätenutzer angewendet, Konflikt erledigt");
|
||||
const second = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "in_progress" }, { baseVersion: stale })] });
|
||||
const secondOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: second.results[0].clientOpId } });
|
||||
ok(second.results[0].status === "conflict", "zweiter veralteter Stand → conflict");
|
||||
await discardSyncConflict(bo, secondOp.id);
|
||||
ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: secondOp.id } })).errorCode === "discarded" && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Verwerfen: nichts angewendet, als verworfen markiert");
|
||||
await expectCode(() => discardSyncConflict(bo, secondOp.id), "not_found", "erledigter Konflikt kann nicht erneut bearbeitet werden");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "sync_operation", entityId: { in: [conflictOp.id, secondOp.id] } } })) >= 4, "Konflikte und Lösungen auditiert");
|
||||
|
||||
section("7. Ungültige Ops, Scope, Mandantentrennung");
|
||||
const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] });
|
||||
ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid");
|
||||
// signature.capture is not registered in services/sync/external-ops.ts (reports lane uses server actions)
|
||||
const unavailable = op("signature.capture", { reportId: "x" });
|
||||
const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] });
|
||||
ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, "nicht verfügbare Op → rejected, nicht gespeichert (später wiederholbar)");
|
||||
const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } });
|
||||
const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] });
|
||||
ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found");
|
||||
const foreign = await applyOperations(B.ctx.tech, { deviceId: "b", operations: [op("note.create", { workOrderId: w, text: "Mandant B" }), op("work_order.transition", { workOrderId: w, to: "cancelled", reason: "x" }, { baseVersion: wo3.version })] });
|
||||
ok(foreign.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Mandant B → not_found (kein Versions-Leak)");
|
||||
ok(foreign.results.every((x) => x.entityVersion === undefined), "Mandant B erhält keine Versionsnummer");
|
||||
await expectCode(() => storeFieldUpload(B.ctx.tech, { clientId: randomUUID(), workOrderId: w, kind: "photo" }, { bytes: Buffer.from("xx"), name: "b.jpg", type: "image/jpeg" }), "not_found", "Mandant B kann keine Datei an den Auftrag hochladen");
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === notesBefore && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Auftrag von A unverändert");
|
||||
ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === w), "Mandant B erhält den Auftrag nicht im Bundle");
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// L10a E2E §43.3 / §38 „Regulärer Auftrag vom Import bis zur Freigabe" — Service-Ebene gegen die DB.
|
||||
//
|
||||
// Import (Fake-Provider) → Dublettenkandidat + Objektkandidat → Prüfung/Bestätigung → Zuweisung →
|
||||
// Vorab-Download (Bundle) → Annehmen/Losfahren/Arbeit → Material/Fotos/Zeiten/Notizen/Checkliste →
|
||||
// Abschlussbericht → Unterschrift → Teamleiter- und Backoffice-Freigabe → PDF (skip-fähig ohne
|
||||
// Chromium) → Abrechnung → abgerechnet → Objekt-Historie. Dazu Mandantentrennung (Mandant B) und
|
||||
// Rollen/Scope (Monteur ohne Zuweisung) an jedem Schritt.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-regular-order.ts (auch mit RLS_ENFORCED=true lauffähig)
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { processImport } from "../src/server/services/imports/process";
|
||||
import { confirmImport } from "../src/server/services/imports/confirm";
|
||||
import { getImportDetail } from "../src/server/services/imports/queries";
|
||||
import { extractionToForm } from "../src/lib/imports/review";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
|
||||
import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries";
|
||||
import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { storeFile } from "../src/server/services/documents/store";
|
||||
import { authorizeDocumentAccess } from "../src/server/services/documents/access";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { generateReportPdf } from "../src/server/services/reports/pdf";
|
||||
import { openReportFile } from "../src/server/services/reports/files";
|
||||
import { contentOf, requireVisibleReport } from "../src/server/services/reports/common";
|
||||
import { pdfRendererAvailable } from "../src/server/pdf/render";
|
||||
import { readStoredBytes } from "../src/server/services/documents/read";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import {
|
||||
checklistTogglePayload,
|
||||
materialUpsertPayload,
|
||||
noteCreatePayload,
|
||||
photoAttachPayload,
|
||||
sessionControlPayload,
|
||||
sessionStartPayload,
|
||||
} from "../src/lib/sync/ops";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-reg-a";
|
||||
const SLUG_B = "zz-q-e2e-reg-b";
|
||||
|
||||
runSuite("E2E regulärer Auftrag", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
// ---------- 1. Import ----------
|
||||
section("1. Import einer Auftragsbestätigung (Fake-Provider)");
|
||||
const pdf = buildPdf([[{ text: "Auftragsbestätigung AB-2026-4711" }, { text: `Hausverwaltung ${SLUG_A} GmbH, Hafenstraße 12, 20457 Hamburg` }]]);
|
||||
const extraction: Partial<WorkOrderExtraction> = {
|
||||
orderNumber: { value: "AB-2026-4711", confidence: 0.97 },
|
||||
customerNumber: { value: `K-${SLUG_A}`, confidence: 0.95 },
|
||||
companyName: { value: `Hausverwaltung ${SLUG_A} GmbH`, confidence: 0.95 },
|
||||
customerAddress: { value: { street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.9 },
|
||||
siteName: { value: `Wohnanlage ${SLUG_A}`, confidence: 0.75, source: "Bauvorhaben" },
|
||||
siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
title: { value: "Austausch Heizungspumpe", confidence: 0.88 },
|
||||
plannedStart: { value: "01.10.2026", confidence: 0.9 },
|
||||
plannedEnd: { value: "02.10.2026", confidence: 0.9 },
|
||||
positions: {
|
||||
value: [
|
||||
{ name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", quantity: 1, unit: "Stk", isMaterial: true },
|
||||
{ name: "Montagearbeiten", quantity: 3, unit: "Std", isMaterial: false },
|
||||
],
|
||||
confidence: 0.86,
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
const job = await createImport(bo, { bytes: pdf, fileName: "AB-2026-4711.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) });
|
||||
ok(job.status === "uploaded" && dispatched.length === 1, "Upload angenommen, Extraktion eingereiht");
|
||||
const processed = await processImport(bo, job.id, { provider: new FakeExtractionProvider({ extraction, text: "AB-2026-4711" }), loadBytes: async () => pdf });
|
||||
ok(processed.status === "review_required", "Extraktion → Prüfung erforderlich");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "kein Auftrag ohne Bestätigung");
|
||||
await expectCode(() => getImportDetail(B.ctx.backoffice, job.id), "not_found", "Mandant B sieht den Import von A nicht");
|
||||
await expectCode(() => getImportDetail(tech, job.id), "forbidden", "Monteur hat keinen Zugriff auf die Prüfmaske");
|
||||
|
||||
// ---------- 2. Prüfung / Bestätigung ----------
|
||||
section("2. Prüfung: bestehender Kunde + bestehendes Objekt zugeordnet");
|
||||
const detail = await getImportDetail(bo, job.id);
|
||||
ok(detail.customerCandidates[0]?.customerId === A.customerId, "Dublettenprüfung schlägt den bestehenden Kunden vor");
|
||||
const siteCandidates = (detail.extraction as unknown as { siteCandidates?: { siteId: string }[] }).siteCandidates ?? [];
|
||||
ok(siteCandidates.some((s) => s.siteId === A.siteId), "bestehendes Objekt an gleicher Adresse als Kandidat");
|
||||
const form = extractionToForm(detail.extraction.fields, { customerId: A.customerId, siteId: A.siteId });
|
||||
form.order!.title = "Heizungspumpe Wohnanlage tauschen";
|
||||
const confirmed = await confirmImport(bo, job.id, form);
|
||||
const wo0 = await prisma.workOrder.findUniqueOrThrow({ where: { id: confirmed.workOrderId }, include: { materialPlans: true } });
|
||||
ok(wo0.status === "planned" && wo0.customerId === A.customerId && wo0.siteId === A.siteId && wo0.sourceImportId === job.id, "Auftrag geplant, Kunde/Objekt zugeordnet, Import verknüpft");
|
||||
ok(wo0.materialPlans.length === 1 && wo0.materialPlans[0].articleNumber === "HP-2560", "Materialvorgabe aus Positionen übernommen");
|
||||
const woId = wo0.id;
|
||||
|
||||
// Backoffice ergänzt Pflichtpunkt + Pflichtfoto.
|
||||
const item = await addChecklistItem(bo, woId, { label: "Anlage entlüftet", required: true });
|
||||
await addPhotoRequirement(bo, woId, { key: "typenschild", label: "Typenschild neue Pumpe" });
|
||||
|
||||
// ---------- 3. Zuweisung ----------
|
||||
section("3. Zuweisung an Team Nord");
|
||||
await expectCode(() => assignWorkOrder(tech, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }), "forbidden", "Monteur darf nicht zuweisen");
|
||||
await expectCode(() => assignWorkOrder(B.ctx.backoffice, { workOrderId: woId, teamId: B.teamId }), "not_found", "Mandant B kann den Auftrag von A nicht zuweisen");
|
||||
const assigned = await assignWorkOrder(bo, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
ok(assigned.status === "assigned", "Auftrag zugewiesen");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "work_order.assigned", entityId: woId } })) === 1, "Monteur erhält Benachrichtigung „Auftrag zugewiesen“");
|
||||
|
||||
// ---------- 4. Vorab-Download ----------
|
||||
section("4. Team lädt Auftrag und Dokumente (Bundle)");
|
||||
const bundle = await getFieldBundle(tech);
|
||||
const bundled = bundle.orders.find((o) => o.id === woId);
|
||||
ok(!!bundled && bundled.site?.accessNotes === "Schlüssel beim Hausmeister" && bundled.materialPlans.length === 1 && bundled.photoRequirements.length === 1, "Bundle enthält Auftrag mit Zugangshinweis, Material, Pflichtfoto");
|
||||
ok(!(await getFieldBundle(A.ctx.outsider)).orders.some((o) => o.id === woId), "Monteur ohne Zuweisung (anderes Team) erhält den Auftrag nicht");
|
||||
ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === woId), "Mandant B erhält den Auftrag nicht");
|
||||
ok(!!bundled && !bundled.documents.some((d) => d.category === "order_confirmation"), "interne Auftragsbestätigung (backoffice_only) nicht im Monteur-Bundle");
|
||||
await expectCode(() => getFieldOrderDetail(A.ctx.outsider, woId), "not_found", "Auftragsdetail für fremden Monteur → not_found");
|
||||
|
||||
// ---------- 5. Einsatz ----------
|
||||
section("5. Einsatz: Annehmen, Losfahren, Arbeiten, Material, Fotos, Zeiten, Notizen");
|
||||
const started = new Date(Date.now() - 3 * 3600_000);
|
||||
await transitionWorkOrder(tech, { workOrderId: woId, to: "accepted" });
|
||||
await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "travel", at: started.toISOString() }));
|
||||
const workStart = await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work", at: new Date(started.getTime() + 30 * 60_000).toISOString() }));
|
||||
ok(workStart.workOrderStatus === "in_progress", "Arbeit gestartet → in Arbeit");
|
||||
await expectCode(() => startSession(A.ctx.outsider, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "fremder Monteur kann keinen Einsatz starten");
|
||||
await expectCode(() => startSession(B.ctx.tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "Mandant B kann keinen Einsatz starten");
|
||||
|
||||
const jpeg = await jpegBytes("Typenschild");
|
||||
const before = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "vorher.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: before.documentId, phase: "before", comment: "Alte Pumpe" }));
|
||||
await createNote(tech, noteCreatePayload.parse({ workOrderId: woId, kind: "work_done", text: "Alte Pumpe demontiert, neue Pumpe eingesetzt." }));
|
||||
await pauseSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 90 * 60_000).toISOString() }));
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } })).status === "paused", "Pause → Auftrag pausiert");
|
||||
await resumeSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 105 * 60_000).toISOString() }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, materialPlanId: wo0.materialPlans[0].id, quantity: 1, unit: "Stk", usageStatus: "fully_used" }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Flachdichtung 1 Zoll", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Alte Dichtungen porös" }));
|
||||
await expectCode(
|
||||
() => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Ohne Grund", quantity: 1, unit: "Stk", usageStatus: "additional" })),
|
||||
"invalid",
|
||||
"Zusatzmaterial ohne Begründung → invalid",
|
||||
);
|
||||
const req = await prisma.photoRequirement.findFirstOrThrow({ where: { workOrderId: woId } });
|
||||
const plate = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: plate.documentId, phase: "after", photoRequirementId: req.id }));
|
||||
await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woId, itemId: item.id, checked: true }));
|
||||
|
||||
await expectCode(() => createCompletionReport(tech, { workOrderId: woId }), "blocked", "Abschlussbericht bei laufender Arbeitszeit → blocked");
|
||||
const ended = await endSession(tech, sessionControlPayload.parse({ workOrderId: woId }));
|
||||
ok(ended.workSeconds > 0 && ended.breakSeconds >= 14 * 60 && ended.travelSeconds >= 29 * 60, `Zeiten berechnet (Arbeit ${Math.round(ended.workSeconds / 60)} min, Pause ${Math.round(ended.breakSeconds / 60)} min, Anfahrt ${Math.round(ended.travelSeconds / 60)} min)`);
|
||||
|
||||
// ---------- 6. Abschlussbericht + Unterschrift ----------
|
||||
section("6. Abschlussbericht und Kundenunterschrift");
|
||||
const { report } = await createCompletionReport(tech, { workOrderId: woId });
|
||||
const content = contentOf(report);
|
||||
ok(content.photos.length === 2 && content.materials.used.length === 1 && content.materials.additional.length === 1, "Berichts-Snapshot enthält Fotos und Material (Soll/Ist + Zusatz)");
|
||||
ok(content.time.totalMinutes > 0 && content.site?.name === `Wohnanlage ${SLUG_A}`, "Berichts-Snapshot enthält Arbeitszeit und Objekt");
|
||||
ok(content.texts.workPerformed.includes("Alte Pumpe demontiert"), "„Ausgeführte Leistungen“ aus den Tätigkeitsnotizen vorbelegt");
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "" } });
|
||||
await expectCode(() => submitReport(tech, { reportId: report.id }), "blocked", "Absenden ohne „Ausgeführte Leistungen“ → blocked");
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Heizungspumpe getauscht, Anlage entlüftet und geprüft." } });
|
||||
const sigImg = await storeFile(tech, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: woId } });
|
||||
await captureSignature(tech, { reportId: report.id, outcome: "signed", signerName: "Frau Prüf", signerRole: "Objektbetreuung", imageDocumentId: sigImg.id, confirmationText: "Arbeiten ordnungsgemäß ausgeführt." });
|
||||
await expectCode(() => captureSignature(B.ctx.tech, { reportId: report.id, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen");
|
||||
await submitReport(tech, { reportId: report.id });
|
||||
const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } });
|
||||
ok(afterSubmit.status === "in_review", "Unterschrift erfasst → Auftrag „Zur Prüfung“");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "report.submitted", entityId: report.id } })) === 1, "Teamleiter wird zur Prüfung benachrichtigt");
|
||||
|
||||
// ---------- 7. Freigabe ----------
|
||||
section("7. Teamleiter- und Backoffice-Freigabe");
|
||||
await expectCode(() => approveReport(tech, { reportId: report.id }, { dispatchPdf: async () => undefined }), "forbidden", "Monteur darf nicht freigeben");
|
||||
await expectCode(() => approveReport(B.ctx.backoffice, { reportId: report.id }, { dispatchPdf: async () => undefined }), "not_found", "Mandant B kann nicht freigeben");
|
||||
await expectCode(() => releaseForBilling(bo, { workOrderId: woId }), "blocked", "Abrechnung vor Berichtsfreigabe → blocked");
|
||||
const teamApproved = await approveReport(A.ctx.lead, { reportId: report.id }, { dispatchPdf: async () => undefined });
|
||||
ok(teamApproved.status === "team_approved", "Teamleiter prüft → team_approved");
|
||||
const pdfJobs: string[] = [];
|
||||
const approved = await approveReport(bo, { reportId: report.id }, { dispatchPdf: async (_c, id) => void pdfJobs.push(id) });
|
||||
ok(approved.status === "approved" && pdfJobs[0] === report.id, "Backoffice gibt frei → approved, PDF-Job angestoßen");
|
||||
await expectCode(() => updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "nachträglich geändert" } }), ["conflict", "invalid", "blocked"], "freigegebener Bericht ist unveränderlich");
|
||||
|
||||
// ---------- 8. PDF ----------
|
||||
section("8. PDF-Arbeitsnachweis");
|
||||
const renderer = await pdfRendererAvailable();
|
||||
if (!renderer.ok) {
|
||||
console.log(`⚠ PDF-Erzeugung übersprungen: kein Chromium startbar (${renderer.reason})`);
|
||||
} else {
|
||||
const pdfRes = await generateReportPdf(bo, report.id);
|
||||
const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
|
||||
const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdfRes.documentId } });
|
||||
ok(!pdfRes.skipped && stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF erzeugt, Prüfsumme am Bericht");
|
||||
ok(doc.category === "completion_report" && doc.siteId === A.siteId && doc.workOrderId === woId, "PDF als Abschlussbericht an Auftrag und Objekt abgelegt");
|
||||
const bytes = await readStoredBytes(doc.storageKey);
|
||||
if (bytes) ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
|
||||
await expectCode(() => openReportFile(B.ctx.backoffice, report.id, "pdf"), "not_found", "Mandant B kann das PDF nicht laden");
|
||||
await expectCode(() => authorizeDocumentAccess(A.ctx.outsider, doc.id), "not_found", "fremder Monteur kann das PDF nicht laden");
|
||||
}
|
||||
|
||||
// ---------- 9. Abrechnung ----------
|
||||
section("9. Abrechnungsfreigabe und abgerechnet");
|
||||
await expectCode(() => releaseForBilling(tech, { workOrderId: woId }), "forbidden", "Monteur darf nicht zur Abrechnung freigeben");
|
||||
await expectCode(() => releaseForBilling(A.ctx.lead, { workOrderId: woId }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben");
|
||||
const released = await releaseForBilling(bo, { workOrderId: woId });
|
||||
ok(released.status === "released_for_billing", "Auftrag zur Abrechnung freigegeben");
|
||||
const billed = await markBilled(bo, { workOrderId: woId });
|
||||
ok(billed.status === "billed", "Auftrag abgerechnet");
|
||||
ok((await codeOf(transitionWorkOrder(bo, { workOrderId: woId, to: "cancelled", reason: "zu spät" }))) === "invalid", "abgerechneter Auftrag kann nicht storniert werden");
|
||||
|
||||
const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: woId }, orderBy: { createdAt: "asc" }, select: { toStatus: true } });
|
||||
const path = history.map((h) => h.toStatus).join(">");
|
||||
ok(path === "planned>assigned>accepted>en_route>in_progress>paused>in_progress>technically_completed>in_review>released_for_billing>billed", `Statushistorie vollständig (${path})`);
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: woId } })) >= 10, "jede Statusänderung auditiert");
|
||||
const woDetail = await getWorkOrderDetail(bo, woId);
|
||||
ok(woDetail.id === woId, "Backoffice-Detail lesbar");
|
||||
|
||||
// ---------- 10. Objekt-Historie ----------
|
||||
section("10. Einsatz erscheint in der Objekt-Historie");
|
||||
const hist = await getSiteHistory(bo, A.siteId);
|
||||
const entry = hist.items.find((i) => i.workOrderId === woId);
|
||||
ok(!!entry && entry.approvedReports.length === 1 && entry.signed && entry.photoCount === 2, "Historie: freigegebener Bericht, Unterschrift, Fotos");
|
||||
ok(!!entry && entry.materials.some((m) => m.name === "Hocheffizienzpumpe 25-60") && entry.workDone.length === 1, "Historie: Material und durchgeführte Arbeiten");
|
||||
const techHistory = await getSiteHistory(A.ctx.tech2, A.siteId);
|
||||
ok(techHistory.onlyApproved && techHistory.items.some((i) => i.workOrderId === woId), "Teamkollege sieht den freigegebenen Einsatz in der Objekt-Historie");
|
||||
await expectCode(() => getSiteHistory(A.ctx.outsider, A.siteId), "not_found", "Monteur ohne Auftrag am Objekt → not_found");
|
||||
await expectCode(() => getSiteHistory(B.ctx.backoffice, A.siteId), "not_found", "Mandant B → not_found");
|
||||
await expectCode(() => requireVisibleReport(B.ctx.backoffice, report.id), "not_found", "Mandant B kann den Bericht nicht lesen");
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
// L10a E2E §43.3 „Mandantentrennung" — systematisch über ALLE Fachmodelle und Fachservices.
|
||||
//
|
||||
// Teil 1 (Datenzugriff): Für JEDES Modell aus TENANT_MODELS (src/server/backup/topology.ts, Spiegel von
|
||||
// db.ts) wird in Mandant A eine Zeile angelegt; aus Mandant B (dbForTenant) werden findMany,
|
||||
// findFirst, findUnique, count, update, updateMany, delete, deleteMany auf genau diese Zeile
|
||||
// versucht → nichts gelesen, nichts geändert. Ein neues Modell ohne Fixture lässt den Test scheitern.
|
||||
// create mit fremder tenantId landet im eigenen Mandanten.
|
||||
// Teil 2 (Postgres-RLS): dieselben Zeilen über die Rolle craftvia_app mit Kontext B → 0 Zeilen sichtbar,
|
||||
// UPDATE/DELETE wirkungslos; Kontext A sieht die Zeile (skip, falls craftvia_app nicht verbinden kann).
|
||||
// Teil 3 (Fachservices): jeder id-basierte Lese-/Schreibpfad (Kunden, Objekte, Teams, Aufträge, Einsatz,
|
||||
// Dokumente, Berichte, Import, Notdienst, Sync, Benachrichtigungen, Audit, Lotse) mit IDs aus A →
|
||||
// not_found (bzw. invalid bei Referenzen), Listen/Suche/Dashboard enthalten keine A-Daten, A unverändert.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-tenant-isolation.ts (zusätzlich mit RLS_ENFORCED=true)
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { TENANT_MODELS, buildTenantTopology } from "../src/server/backup/topology";
|
||||
import { getCustomer, updateCustomer, deleteCustomer, confirmProvisionalCustomer, listCustomers, listCustomerWorkOrders } from "../src/server/services/customers/customers";
|
||||
import { createContact } from "../src/server/services/customers/contacts";
|
||||
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||
import { getSite, updateSite, deleteSite, createSite, listSites } from "../src/server/services/sites/sites";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import { getTeam, updateTeam, deleteTeam, listTeams } from "../src/server/services/teams/teams";
|
||||
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
|
||||
import { updateWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { cancelWorkOrder } from "../src/server/services/work-orders/cancel";
|
||||
import { addMaterialPlan } from "../src/server/services/work-orders/materials";
|
||||
import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist";
|
||||
import { getCompletionBlockers } from "../src/server/services/work-orders/completion";
|
||||
import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { uploadWorkOrderDocument } from "../src/server/services/work-orders/documents";
|
||||
import { listWorkOrders } from "../src/server/services/work-orders/list";
|
||||
import { searchAll } from "../src/server/services/work-orders/search";
|
||||
import { getDashboardTiles } from "../src/server/services/work-orders/dashboard";
|
||||
import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts";
|
||||
import { startSession } from "../src/server/services/field/sessions";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { attachVoiceNote } from "../src/server/services/field/voice";
|
||||
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
||||
import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries";
|
||||
import { storeFile } from "../src/server/services/documents/store";
|
||||
import { authorizeDocumentAccess, deleteDocument, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access";
|
||||
import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { rejectReport } from "../src/server/services/reports/reject";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { createNewVersion } from "../src/server/services/reports/new-version";
|
||||
import { openReportFile } from "../src/server/services/reports/files";
|
||||
import { generateReportPdf } from "../src/server/services/reports/pdf";
|
||||
import { requireVisibleReport } from "../src/server/services/reports/common";
|
||||
import { getReportDetail, listReports } from "../src/server/services/reports/queries";
|
||||
import { getImportDetail, listImports } from "../src/server/services/imports/queries";
|
||||
import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm";
|
||||
import { getEmergencyReview, confirmEmergencyCustomer, listEmergencyReviews } from "../src/server/services/emergency/review";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { listNotifications, markRead } from "../src/server/services/notifications/inbox";
|
||||
import { getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer";
|
||||
import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol";
|
||||
import { getLotseReportState } from "../src/server/services/lotse/state";
|
||||
import { noteCreatePayload, sessionStartPayload, materialUpsertPayload, checklistTogglePayload, photoAttachPayload, voiceAttachPayload } from "../src/lib/sync/ops";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { codeOf, createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-iso-a";
|
||||
const SLUG_B = "zz-q-e2e-iso-b";
|
||||
const MARK = "HACKED-BY-B";
|
||||
|
||||
type Rows = Record<string, { id: string; update: Record<string, unknown> }>;
|
||||
|
||||
/** One row per tenant model in tenant A (owner client) + the field a foreign update would try to change. */
|
||||
async function createModelRows(A: TenantFixture): Promise<Rows> {
|
||||
const t = A.tenantId;
|
||||
const uid = A.users.tech.id;
|
||||
const rows: Rows = {};
|
||||
const put = (model: string, id: string, update: Record<string, unknown>) => (rows[model] = { id, update });
|
||||
|
||||
put("User", uid, { name: MARK });
|
||||
put("Role", (await prisma.role.findFirstOrThrow({ where: { tenantId: t } })).id, { name: MARK });
|
||||
put("AuditLog", (await prisma.auditLog.create({ data: { tenantId: t, action: "create", entity: "zz", entityId: "x", after: { v: 1 } } })).id, { entity: MARK });
|
||||
put("MailLog", (await prisma.mailLog.create({ data: { tenantId: t, to: "a@zz-qualitaet.test", template: "zz" } })).id, { status: MARK });
|
||||
put("NotificationPreference", (await prisma.notificationPreference.create({ data: { tenantId: t, userId: uid, eventType: "zz.event" } })).id, { eventType: MARK });
|
||||
put("AuthToken", (await prisma.authToken.create({ data: { principalType: "tenant_user", principalId: uid, tenantId: t, type: "password_reset", tokenHash: randomUUID(), expiresAt: new Date(Date.now() + 3600_000) } })).id, { type: MARK });
|
||||
put("TenantSettings", (await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: t } })).id, { orgName: MARK });
|
||||
put("TenantModule", (await prisma.tenantModule.create({ data: { tenantId: t, moduleKey: "lotse", enabled: true } })).id, { moduleKey: MARK });
|
||||
put("NumberSequence", (await prisma.numberSequence.create({ data: { tenantId: t, key: "zz", prefix: "Z-" } })).id, { prefix: MARK });
|
||||
const orderType = await prisma.orderType.create({ data: { tenantId: t, key: "zz_typ", name: "ZZ Typ" } });
|
||||
put("OrderType", orderType.id, { name: MARK });
|
||||
put("ChecklistTemplate", (await prisma.checklistTemplate.create({ data: { tenantId: t, name: "ZZ Vorlage" } })).id, { name: MARK });
|
||||
put("Customer", A.customerId, { companyName: MARK });
|
||||
put("Contact", A.contactId, { name: MARK });
|
||||
put("Site", A.siteId, { name: MARK });
|
||||
put("Team", A.teamId, { name: MARK });
|
||||
put("TeamMember", (await prisma.teamMember.findFirstOrThrow({ where: { tenantId: t, userId: uid } })).id, { validTo: new Date(0) });
|
||||
|
||||
const wo = await prisma.workOrder.create({ data: { tenantId: t, number: "ZZ-ISO-1", customerId: A.customerId, siteId: A.siteId, title: "Isolation", status: "in_progress", assignedTeamId: A.teamId } });
|
||||
put("WorkOrder", wo.id, { title: MARK });
|
||||
put("WorkOrderAssignee", (await prisma.workOrderAssignee.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid } })).id, { userId: A.users.tech2.id });
|
||||
put("WorkOrderStatusChange", (await prisma.workOrderStatusChange.create({ data: { tenantId: t, workOrderId: wo.id, toStatus: "in_progress", actorId: uid } })).id, { reason: MARK });
|
||||
put("ChecklistItem", (await prisma.checklistItem.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK });
|
||||
put("PhotoRequirement", (await prisma.photoRequirement.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK });
|
||||
const plan = await prisma.materialPlan.create({ data: { tenantId: t, workOrderId: wo.id, name: "ZZ", plannedQuantity: new Prisma.Decimal(1), unit: "Stk" } });
|
||||
put("MaterialPlan", plan.id, { name: MARK });
|
||||
put("MaterialUsage", (await prisma.materialUsage.create({ data: { tenantId: t, workOrderId: wo.id, materialPlanId: plan.id, name: "ZZ", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "fully_used" } })).id, { notes: MARK });
|
||||
const session = await prisma.workSession.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid, status: "ended", startedAt: new Date(Date.now() - 3600_000), endedAt: new Date() } });
|
||||
put("WorkSession", session.id, { deviceInfo: MARK });
|
||||
put("TimeEntry", (await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: session.id, userId: uid, startedAt: session.startedAt, endedAt: new Date() } })).id, { correctionReason: MARK });
|
||||
put("ActivityNote", (await prisma.activityNote.create({ data: { tenantId: t, workOrderId: wo.id, authorId: uid, text: "ZZ" } })).id, { text: MARK });
|
||||
const doc = (key: string) =>
|
||||
prisma.document.create({ data: { tenantId: t, workOrderId: wo.id, category: "photo", fileName: `${key}.jpg`, storageKey: `${t}/${key}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } });
|
||||
const d1 = await doc("iso-photo");
|
||||
const d2 = await doc("iso-voice");
|
||||
const d3 = await doc("iso-import");
|
||||
put("Document", d1.id, { title: MARK });
|
||||
put("Photo", (await prisma.photo.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d1.id, takenAt: new Date() } })).id, { comment: MARK });
|
||||
put("VoiceNote", (await prisma.voiceNote.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d2.id, recordedAt: new Date() } })).id, { transcript: MARK });
|
||||
const report = await prisma.report.create({ data: { tenantId: t, workOrderId: wo.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), content: {} } });
|
||||
put("Report", report.id, { rejectionReason: MARK });
|
||||
put("Signature", (await prisma.signature.create({ data: { tenantId: t, reportId: report.id, outcome: "later", reason: "ZZ", signedAt: new Date() } })).id, { reason: MARK });
|
||||
put("ImportJob", (await prisma.importJob.create({ data: { tenantId: t, documentId: d3.id, status: "uploaded" } })).id, { errorMessage: MARK });
|
||||
put("Notification", (await prisma.notification.create({ data: { tenantId: t, userId: uid, type: "zz", title: "ZZ", message: "ZZ" } })).id, { title: MARK });
|
||||
put("SyncOperation", (await prisma.syncOperation.create({ data: { tenantId: t, userId: uid, clientOpId: randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } })).id, { errorCode: MARK });
|
||||
put("AiGeneration", (await prisma.aiGeneration.create({ data: { tenantId: t, kind: "report_draft", provider: "fake", model: "zz" } })).id, { model: MARK });
|
||||
return rows;
|
||||
}
|
||||
|
||||
type Delegate = Record<string, (args: unknown) => Promise<unknown>>;
|
||||
const lc = (m: string) => m.charAt(0).toLowerCase() + m.slice(1);
|
||||
const snapshotRow = async (model: string, id: string) => JSON.stringify(await (prisma as unknown as Record<string, Delegate>)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
|
||||
async function threw(fn: () => Promise<unknown>): Promise<boolean> {
|
||||
try {
|
||||
await fn();
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRlsUrl(): string {
|
||||
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
|
||||
base.username = "craftvia_app";
|
||||
base.password = "craftvia_app_local";
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
console.log(`RLS_ENFORCED=${process.env.RLS_ENFORCED === "true" ? "true" : "false"}`);
|
||||
|
||||
// ================= Teil 1: alle Tenant-Modelle =================
|
||||
section("Teil 1: jedes Tenant-Modell aus Mandant B unerreichbar");
|
||||
const rows = await createModelRows(A);
|
||||
const missing = TENANT_MODELS.filter((m) => !rows[m]);
|
||||
ok(missing.length === 0, `Fixture deckt alle ${TENANT_MODELS.length} Tenant-Modelle ab${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`);
|
||||
const dbB = dbForTenant(B.tenantId) as unknown as Record<string, Delegate>;
|
||||
for (const model of TENANT_MODELS) {
|
||||
const row = rows[model];
|
||||
if (!row) continue;
|
||||
const d = dbB[lc(model)];
|
||||
const before = await snapshotRow(model, row.id);
|
||||
const where = { id: row.id };
|
||||
const results = {
|
||||
findMany: ((await d.findMany({ where })) as unknown[]).length === 0,
|
||||
findFirst: (await d.findFirst({ where })) === null,
|
||||
findUnique: (await d.findUnique({ where }).catch(() => null)) === null,
|
||||
count: (await d.count({ where })) === 0,
|
||||
updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0,
|
||||
deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0,
|
||||
update: await threw(() => d.update({ where, data: row.update })),
|
||||
delete: await threw(() => d.delete({ where })),
|
||||
};
|
||||
const after = await snapshotRow(model, row.id);
|
||||
const failed = Object.entries(results).filter(([, v]) => !v).map(([k]) => k);
|
||||
ok(failed.length === 0 && before === after && after !== "null", `${model}: lesen/ändern/löschen aus B abgewiesen, Zeile unverändert${failed.length ? ` — durchgelassen: ${failed.join(", ")}` : ""}${before !== after ? " — ZEILE GEÄNDERT" : ""}`);
|
||||
}
|
||||
const injected = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: A.tenantId, companyName: "Untergeschoben" } });
|
||||
ok(injected.tenantId === B.tenantId, "create mit fremder tenantId landet im eigenen Mandanten (B)");
|
||||
const auditA = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz" } });
|
||||
ok((await threw(() => dbForTenant(B.tenantId).auditLog.update({ where: { id: auditA.id }, data: { after: { v: 2 } } }))) && (await prisma.auditLog.findUniqueOrThrow({ where: { id: auditA.id } })).entity === "zz", "Audit-Log von A aus B nicht manipulierbar");
|
||||
|
||||
// ================= Teil 2: Postgres-RLS =================
|
||||
section("Teil 2: Postgres Row Level Security (Rolle craftvia_app)");
|
||||
const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) });
|
||||
try {
|
||||
await app.$queryRawUnsafe("SELECT 1");
|
||||
const topo = buildTenantTopology();
|
||||
for (const model of TENANT_MODELS) {
|
||||
const row = rows[model];
|
||||
if (!row) continue;
|
||||
const table = topo.nodes.get(model)!.table;
|
||||
const res = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`;
|
||||
const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id);
|
||||
const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id);
|
||||
const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id);
|
||||
return { seen: seen[0].n, updated, deleted };
|
||||
});
|
||||
const own = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
|
||||
return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n;
|
||||
});
|
||||
ok(res.seen === 0 && res.updated === 0 && res.deleted === 0 && own === 1, `RLS ${table}: Kontext B sieht/ändert/löscht nichts, Kontext A sieht die Zeile`);
|
||||
}
|
||||
const noCtx = (await app.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "work_orders" WHERE tenant_id = $1`, A.tenantId))[0].n;
|
||||
ok(noCtx === 0, "RLS ohne Mandantenkontext: 0 Zeilen (fail-closed)");
|
||||
} catch (err) {
|
||||
if (process.env.RLS_TEST_REQUIRED === "true") throw err;
|
||||
console.log(`⚠ Teil 2 übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`);
|
||||
} finally {
|
||||
await app.$disconnect();
|
||||
}
|
||||
|
||||
// ================= Teil 3: Fachservices =================
|
||||
section("Teil 3: Fachservices mit IDs aus Mandant A");
|
||||
// real objects in A (built through the services)
|
||||
const woA = await createWorkOrder(A.ctx.backoffice, { title: "Isolation real", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, checklistItems: [{ label: "Prüfen", required: false }], materials: [{ name: "Rohr", plannedQuantity: 2, unit: "m" }] });
|
||||
await assignWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work", at: new Date(Date.now() - 7200_000).toISOString() }));
|
||||
const photoA = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("A"), name: "a.jpg", type: "image/jpeg" });
|
||||
await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: woA.id, kind: "work_done", text: "Arbeit in A" }));
|
||||
const dailyA = (await createDailyReport(A.ctx.tech, { workOrderId: woA.id })).report;
|
||||
const itemA = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: woA.id } });
|
||||
const planA = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: woA.id } });
|
||||
const teA = await prisma.timeEntry.findFirstOrThrow({ where: { tenantId: A.tenantId, userId: A.users.tech.id, workSession: { workOrderId: woA.id } } });
|
||||
const docA = await prisma.document.findUniqueOrThrow({ where: { id: photoA.documentId } });
|
||||
const provisional = await prisma.customer.create({ data: { tenantId: A.tenantId, lastName: "Vorläufig", status: "provisional", isProvisional: true } });
|
||||
const ids = { ...Object.fromEntries(Object.entries(rows).map(([k, v]) => [k, v.id])), woA: woA.id, dailyA: dailyA.id, docA: docA.id };
|
||||
|
||||
const watched = async () =>
|
||||
JSON.stringify(
|
||||
await Promise.all([
|
||||
prisma.customer.findUnique({ where: { id: A.customerId } }),
|
||||
prisma.site.findUnique({ where: { id: A.siteId } }),
|
||||
prisma.team.findUnique({ where: { id: A.teamId }, include: { members: true } }),
|
||||
prisma.workOrder.findUnique({ where: { id: woA.id }, include: { assignees: true, checklistItems: true, materialPlans: true, materialUsages: true, notes: true, photos: true } }),
|
||||
prisma.report.findUnique({ where: { id: dailyA.id } }),
|
||||
prisma.document.findUnique({ where: { id: docA.id } }),
|
||||
prisma.importJob.findUnique({ where: { id: rows.ImportJob.id } }),
|
||||
prisma.timeEntry.findUnique({ where: { id: teA.id } }),
|
||||
prisma.notification.findUnique({ where: { id: rows.Notification.id } }),
|
||||
prisma.syncOperation.findUnique({ where: { id: rows.SyncOperation.id } }),
|
||||
]),
|
||||
);
|
||||
const beforeAll = await watched();
|
||||
|
||||
const bo: ServiceCtx = B.ctx.backoffice;
|
||||
const admin: ServiceCtx = B.ctx.admin;
|
||||
const tech: ServiceCtx = B.ctx.tech;
|
||||
const NF = ["not_found"];
|
||||
const REF = ["not_found", "invalid"];
|
||||
const checks: [string, () => Promise<unknown>, string[]][] = [
|
||||
// Kunden / Objekte / Teams
|
||||
["getCustomer", () => getCustomer(bo, A.customerId), NF],
|
||||
["updateCustomer", () => updateCustomer(bo, A.customerId, { companyName: MARK }), NF],
|
||||
["deleteCustomer", () => deleteCustomer(bo, A.customerId), NF],
|
||||
["confirmProvisionalCustomer", () => confirmProvisionalCustomer(bo, provisional.id), NF],
|
||||
["createContact (Kunde A)", () => createContact(bo, A.customerId, { name: MARK }), NF],
|
||||
["listCustomerWorkOrders (Kunde A)", async () => { const r = await listCustomerWorkOrders(bo, A.customerId); if (JSON.stringify(r).includes(woA.id)) throw new Error("leak"); throw Object.assign(new Error("empty"), { code: "not_found" }); }, NF],
|
||||
["mergeCustomers (Ziel A)", () => mergeCustomers(admin, { sourceId: B.customerId, targetId: A.customerId, confirm: true }), NF],
|
||||
["getSite", () => getSite(bo, A.siteId), NF],
|
||||
["updateSite", () => updateSite(bo, A.siteId, { name: MARK }), NF],
|
||||
["deleteSite", () => deleteSite(bo, A.siteId), NF],
|
||||
["getSiteHistory", () => getSiteHistory(bo, A.siteId), NF],
|
||||
["createSite (Kunde A)", () => createSite(bo, { customerId: A.customerId, name: MARK }), REF],
|
||||
["getTeam", () => getTeam(admin, A.teamId), NF],
|
||||
["updateTeam", () => updateTeam(admin, A.teamId, { name: MARK }), NF],
|
||||
["deleteTeam", () => deleteTeam(admin, A.teamId), NF],
|
||||
// Aufträge
|
||||
["getWorkOrderDetail", () => getWorkOrderDetail(bo, woA.id), NF],
|
||||
["updateWorkOrder", () => updateWorkOrder(bo, woA.id, { title: MARK }), NF],
|
||||
["transitionWorkOrder", () => transitionWorkOrder(bo, { workOrderId: woA.id, to: "cancelled", reason: MARK }), NF],
|
||||
["assignWorkOrder", () => assignWorkOrder(bo, { workOrderId: woA.id, teamId: B.teamId }), NF],
|
||||
["cancelWorkOrder", () => cancelWorkOrder(bo, { workOrderId: woA.id, reason: MARK }), NF],
|
||||
["addMaterialPlan", () => addMaterialPlan(bo, woA.id, { name: MARK, plannedQuantity: 1, unit: "Stk" }), NF],
|
||||
["addChecklistItem", () => addChecklistItem(bo, woA.id, { label: MARK }), NF],
|
||||
["addPhotoRequirement", () => addPhotoRequirement(bo, woA.id, { label: MARK }), NF],
|
||||
["getCompletionBlockers", () => getCompletionBlockers(bo, woA.id), NF],
|
||||
["releaseForBilling", () => releaseForBilling(bo, { workOrderId: woA.id }), NF],
|
||||
["markBilled", () => markBilled(bo, { workOrderId: woA.id }), NF],
|
||||
["createWorkOrder (Kunde A)", () => createWorkOrder(bo, { title: MARK, customerId: A.customerId }), REF],
|
||||
["uploadWorkOrderDocument", async () => uploadWorkOrderDocument(bo, { workOrderId: woA.id, bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "other", visibility: "team" }), NF],
|
||||
// Einsatz
|
||||
["getFieldOrderDetail", () => getFieldOrderDetail(tech, woA.id), NF],
|
||||
["startSession", () => startSession(tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })), NF],
|
||||
["createNote", () => createNote(tech, noteCreatePayload.parse({ workOrderId: woA.id, text: MARK })), NF],
|
||||
["upsertMaterialUsage", () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woA.id, materialPlanId: planA.id, quantity: 9, unit: "m", usageStatus: "fully_used" })), NF],
|
||||
["toggleChecklistItem", () => toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woA.id, itemId: itemA.id, checked: true })), NF],
|
||||
["storeFieldUpload", async () => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("B"), name: "b.jpg", type: "image/jpeg" }), NF],
|
||||
["attachPhoto (Dokument A)", () => attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woA.id, documentId: docA.id })), NF],
|
||||
["attachVoiceNote (Dokument A)", () => attachVoiceNote(tech, voiceAttachPayload.parse({ workOrderId: woA.id, documentId: rows.Document.id })), NF],
|
||||
["correctTimeEntry", () => correctTimeEntry(admin, { timeEntryId: teA.id, startedAt: new Date(Date.now() - 600_000), reason: MARK }), NF],
|
||||
// Dokumente
|
||||
["authorizeDocumentAccess", () => authorizeDocumentAccess(bo, docA.id), NF],
|
||||
["openDocumentContent", () => openDocumentContent(bo, docA.id), NF],
|
||||
["updateDocumentMeta", () => updateDocumentMeta(bo, docA.id, { title: MARK }), NF],
|
||||
["deleteDocument", () => deleteDocument(bo, docA.id), NF],
|
||||
["storeFile (Auftrag A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woA.id } }), NF],
|
||||
["storeFile (Version von A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", lineageId: docA.lineageId }), REF],
|
||||
// Berichte
|
||||
["requireVisibleReport", () => requireVisibleReport(bo, dailyA.id), NF],
|
||||
["getReportDetail", () => getReportDetail(bo, dailyA.id), NF],
|
||||
["createDailyReport", () => createDailyReport(tech, { workOrderId: woA.id }), NF],
|
||||
["createCompletionReport", () => createCompletionReport(tech, { workOrderId: woA.id }), NF],
|
||||
["updateReportTexts", () => updateReportTexts(tech, { reportId: dailyA.id, texts: { hints: MARK } }), NF],
|
||||
["submitReport", () => submitReport(tech, { reportId: dailyA.id }), NF],
|
||||
["approveReport", () => approveReport(bo, { reportId: dailyA.id }, { dispatchPdf: async () => undefined }), NF],
|
||||
["rejectReport", () => rejectReport(bo, { reportId: dailyA.id, reason: MARK }), NF],
|
||||
["captureSignature", () => captureSignature(tech, { reportId: dailyA.id, outcome: "later", reason: MARK, confirmationText: MARK }), NF],
|
||||
["createNewVersion", () => createNewVersion(bo, { reportId: dailyA.id }), NF],
|
||||
["openReportFile", () => openReportFile(bo, dailyA.id, docA.id), NF],
|
||||
["generateReportPdf", () => generateReportPdf(bo, dailyA.id), NF],
|
||||
["getLotseReportState", () => getLotseReportState(tech, dailyA.id), NF],
|
||||
// Import / Notdienst / Sync / Benachrichtigungen / Audit / KI
|
||||
["getImportDetail", () => getImportDetail(bo, rows.ImportJob.id), NF],
|
||||
["confirmImport", () => confirmImport(bo, rows.ImportJob.id, {}), REF],
|
||||
["discardImport", () => discardImport(bo, rows.ImportJob.id), NF],
|
||||
["retryImport", () => retryImport(bo, rows.ImportJob.id, { dispatch: async () => undefined }), NF],
|
||||
["getEmergencyReview", () => getEmergencyReview(bo, woA.id), NF],
|
||||
["confirmEmergencyCustomer", () => confirmEmergencyCustomer(bo, woA.id), NF],
|
||||
["applySyncConflict", () => applySyncConflict(bo, rows.SyncOperation.id), NF],
|
||||
["discardSyncConflict", () => discardSyncConflict(bo, rows.SyncOperation.id), NF],
|
||||
["markRead (Benachrichtigung A)", () => markRead(tech, rows.Notification.id), NF],
|
||||
["getAuditEntry", () => getAuditEntry(admin, rows.AuditLog.id), NF],
|
||||
["getAiGenerationContent", () => getAiGenerationContent(admin, rows.AiGeneration.id), NF],
|
||||
];
|
||||
for (const [name, fn, expected] of checks) {
|
||||
const got = await codeOf(fn);
|
||||
ok(expected.includes(got), `${name} aus Mandant B → ${got}`);
|
||||
}
|
||||
const sync = await applyOperations(tech, {
|
||||
deviceId: "b",
|
||||
operations: [
|
||||
{ clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: woA.id, text: MARK }, clientCreatedAt: new Date().toISOString() },
|
||||
{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: 1, payload: { workOrderId: woA.id, to: "paused" }, clientCreatedAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
ok(sync.results.every((r) => r.status === "rejected" && r.errorCode === "not_found"), "Sync-Ops auf Auftrag von A → rejected not_found");
|
||||
|
||||
section("Listen, Suche, Dashboard enthalten keine Daten von A");
|
||||
const aIds = Object.values(ids);
|
||||
const noLeak = (label: string, value: unknown) => {
|
||||
const text = JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
||||
const leaked = aIds.filter((id) => text.includes(id));
|
||||
ok(leaked.length === 0 && !text.includes(`Hausverwaltung ${SLUG_A}`), `${label}: keine A-Daten${leaked.length ? ` — enthält ${leaked.length} IDs` : ""}`);
|
||||
};
|
||||
noLeak("listWorkOrders", await listWorkOrders(bo, { sort: "plannedStart", dir: "asc", page: 1, pageSize: 100 }));
|
||||
noLeak("listReports", await listReports(bo, { status: "all" }));
|
||||
noLeak("listDocuments", await listDocuments(bo, { pageSize: 500 }));
|
||||
noLeak("listCustomers", await listCustomers(bo, { status: "all", pageSize: 100 }));
|
||||
noLeak("listSites", await listSites(bo, { status: "all", pageSize: 100 }));
|
||||
noLeak("listTeams", await listTeams(bo, { includeInactive: true }));
|
||||
noLeak("searchAll", await searchAll(bo, { q: SLUG_A.slice(0, 12) }));
|
||||
noLeak("searchAll (Auftragstitel)", await searchAll(bo, { q: "Isolation" }));
|
||||
noLeak("listImports", await listImports(bo));
|
||||
noLeak("listEmergencyReviews", await listEmergencyReviews(bo, { filter: "all" }));
|
||||
noLeak("listSyncConflicts", await listSyncConflicts(bo));
|
||||
noLeak("listNotifications", await listNotifications(tech, {}));
|
||||
noLeak("queryAuditLog", await queryAuditLog(admin, {}));
|
||||
noLeak("listAiGenerations", await listAiGenerations(admin));
|
||||
noLeak("getFieldBundle", await getFieldBundle(tech));
|
||||
const tiles = await getDashboardTiles(bo, {});
|
||||
ok(tiles.open === 0 && tiles.running === 0 && tiles.syncConflicts === 0 && tiles.reportsToReview === 0, "Dashboard von B zählt keine Aufträge/Konflikte/Berichte von A");
|
||||
|
||||
ok((await watched()) === beforeAll, "Daten von Mandant A nach allen Versuchen unverändert");
|
||||
});
|
||||
Reference in New Issue
Block a user