162 lines
15 KiB
TypeScript
162 lines
15 KiB
TypeScript
// 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<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");
|
||
// 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");
|
||
});
|