Files
craftvia/scripts/test-e2e-offline-sync.ts
T

162 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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");
});