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,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");
|
||||
});
|
||||
Reference in New Issue
Block a user