Files
craftvia/scripts/test-e2e-guards.ts

152 lines
13 KiB
TypeScript

// 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");
});