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:
2026-09-14 18:16:11 +02:00
co-authored by Claude Opus 5
parent 1a75c28bb4
commit 4507862375
8 changed files with 1539 additions and 0 deletions
+153
View File
@@ -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");
});