// 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 { CONFLICTING_OPS, SYNC_OP_TYPES, type SyncOperationInput } from "../src/lib/sync/envelope"; import { EXTERNAL_OPS } from "../src/server/services/sync/external-ops"; 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, extra: Partial = {}, 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"); // An op type without server handler (neither field handler nor registered in services/sync/external-ops.ts) // must be answered without being stored, so devices can retry after the owning lane is deployed. const FIELD_OPS = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "note.create", "checklist.toggle", "material.upsert", "photo.attach", "voice.attach"]; const unregistered = SYNC_OP_TYPES.find((t) => !FIELD_OPS.includes(t) && !EXTERNAL_OPS[t] && !CONFLICTING_OPS.includes(t)); if (unregistered) { const unavailable = op(unregistered, { 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 ${unregistered} → rejected, nicht gespeichert (später wiederholbar)`); } else { console.log("• alle Sync-Op-Typen sind registriert — Prüfung „nicht verfügbare Op“ entfällt"); } 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"); });