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

194 lines
16 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.
// Lane L7 „Offline & PWA" — Ende-zu-Ende gegen den echten Sync-Service (ohne HTTP):
// Outbox (In-Memory-Store) + Sync-Engine + applyOperations/storeFieldUpload.
// - 20 Ops offline erfasst (10 Tage alt, älter als OFFLINE_MAX_DAYS) → Foto-Upload vor der Op →
// EIN Batch → alle applied, clientCreatedAt serverseitig protokolliert
// - Wiederholung desselben Batches → alle duplicate, keine Doppelanlagen
// - Konflikt (Auftrag im Büro geändert) stoppt keine unabhängige Op
// - Mandantentrennung: Mandant B kann Auftrag von A weder lesen (bundle) noch ändern (Ops/Upload)
// - Rollen/Scope: Monteur ohne Zuweisung → not_found
//
// Lauf: npx tsx scripts/test-offline-sync-e2e.ts (lokale Postgres-DB aus .env; S3 optional)
import "dotenv/config";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { prisma } from "../src/server/db";
import { closeJobQueues } from "../src/server/jobs/queues";
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 type { ServiceCtx } from "../src/server/services/context";
import type { SyncOperationInput } from "../src/lib/sync/envelope";
import { createMemoryStore } from "../src/lib/offline/memory-store";
import { blobRef, selectBatch } from "../src/lib/offline/outbox-core";
import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine";
import { selectOfflineOrders } from "../src/lib/offline/bundle-core";
import { ctxKeyOf, type OfflineContext, type OfflineStore } from "../src/lib/offline/types";
import { createFixture, failures, ok } from "./lib/einsatz-fixture";
type Stats = { batches: SyncOperationInput[][]; uploads: number };
/** Transport that calls the server services in-process with the given (authenticated) context. */
function serverTransport(ctx: ServiceCtx): { transport: Transport; stats: Stats } {
const stats: Stats = { batches: [], uploads: 0 };
const transport: Transport = {
async sendBatch(deviceId, operations) {
stats.batches.push(operations);
return { ok: true, response: await applyOperations(ctx, { deviceId, operations }) };
},
async upload(blob) {
stats.uploads++;
try {
const r = await storeFieldUpload(
ctx,
{ clientId: blob.clientId, workOrderId: blob.workOrderId, kind: blob.kind },
{ bytes: Buffer.from(await blob.blob.arrayBuffer()), name: blob.fileName, type: blob.blob.type },
null,
);
return { ok: true, documentId: r.documentId };
} catch (err) {
const code = (err as { code?: string }).code;
return { ok: false, error: code === "not_found" || code === "forbidden" || code === "invalid" ? code : "internal" };
}
},
};
return { transport, stats };
}
const localCtx = (ctx: ServiceCtx): OfflineContext => ({ tenantId: ctx.tenantId, userId: ctx.userId });
async function main() {
const f = await createFixture("l7e2e");
const wo = f.orderA;
const A = localCtx(f.ctxTech);
const store: OfflineStore = createMemoryStore();
try {
const jpeg = await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 30, g: 90, b: 160 } } }).jpeg().toBuffer();
console.log("\n— Vorab-Download (Bundle im Scope) —");
const bundle = await getFieldBundle(f.ctxTech);
const offlineOrders = selectOfflineOrders(bundle.orders.map((o) => ({ ...o, plannedStart: o.plannedStart?.toISOString() ?? null, plannedEnd: o.plannedEnd?.toISOString() ?? null })), new Date());
ok(offlineOrders.some((o) => o.id === wo.id), "heutiger Auftrag des Monteurs wird offline gespeichert");
ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo.id), "Mandant B erhält Auftrag von A nicht im Bundle");
ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo.id), "Monteur ohne Zuweisung erhält den Auftrag nicht im Bundle");
console.log("\n— 20 Ops offline erfassen —");
const offlineAt = Date.now() - 10 * 24 * 60 * 60 * 1000; // older than OFFLINE_MAX_DAYS (7)
let minute = 0;
const when = () => new Date(offlineAt + minute++ * 60_000);
const add = (op: Parameters<typeof enqueueOp>[2]) => enqueueOp(store, A, op, randomUUID(), when());
await add({ opType: "work_order.transition", baseVersion: wo.version, payload: { workOrderId: wo.id, to: "accepted" } });
await add({ opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID(), offline: true } });
for (let i = 1; i <= 8; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "work_done", text: `Offline-Notiz ${i}` } });
await add({ opType: "checklist.toggle", payload: { workOrderId: wo.id, itemId: wo.checklistItems[0].id, checked: true } });
await add({ opType: "material.upsert", payload: { workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, quantity: 10, unit: "m", usageStatus: "fully_used" } });
await add({ opType: "material.upsert", payload: { workOrderId: wo.id, clientId: randomUUID(), name: "Dichtband", quantity: 2, unit: "Rolle", usageStatus: "additional", deviationReason: "Undichte Verschraubung" } });
const photoClient = randomUUID();
const ref = await enqueueBlob(store, A, { clientId: photoClient, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "typenschild.jpg" }, when());
ok(ref === blobRef(photoClient), "Foto in die Upload-Warteschlange aufgenommen");
await add({ opType: "photo.attach", payload: { workOrderId: wo.id, clientId: randomUUID(), documentId: ref, phase: "after", photoRequirementId: wo.photoRequirements[0].id } });
await add({ opType: "session.pause", payload: { workOrderId: wo.id } });
await add({ opType: "session.resume", payload: { workOrderId: wo.id } });
for (let i = 9; i <= 11; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: `Offline-Notiz ${i}` } });
await add({ opType: "session.end", payload: { workOrderId: wo.id } });
const queued = await store.listOps(ctxKeyOf(A));
ok(queued.length === 20 && queued.every((o) => o.status === "queued"), "20 Ops lokal in der Outbox (queued)");
const beforeUpload = selectBatch(queued, await store.listBlobs(ctxKeyOf(A)), new Date());
ok(beforeUpload.entries.length === 13, "vor dem Upload: Ops ab dem Foto warten (Reihenfolge je Auftrag)");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 0, "offline: noch nichts auf dem Server");
console.log("\n— Verbindung da: ein Batch —");
const tech = serverTransport(f.ctxTech);
const pass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
ok(tech.stats.uploads === 1 && tech.stats.batches.length === 1 && tech.stats.batches[0].length === 20, "1 Upload, dann genau 1 Batch mit 20 Ops");
ok(pass.applied === 20 && pass.conflicts === 0 && pass.rejected === 0, `alle 20 Ops applied (applied=${pass.applied}, conflict=${pass.conflicts}, rejected=${pass.rejected})`);
const afterPass = await store.listOps(ctxKeyOf(A));
ok(afterPass.every((o) => o.status === "applied" && o.result?.status === "applied"), "lokaler Status aller Ops: applied");
if (pass.applied !== 20) console.log(afterPass.filter((o) => o.status !== "applied").map((o) => `${o.opType}: ${o.status} ${o.lastError?.code} ${o.lastError?.message}`));
const photoOp = tech.stats.batches[0].find((o) => o.opType === "photo.attach");
ok(!!photoOp && typeof photoOp.payload.documentId === "string" && !String(photoOp.payload.documentId).startsWith("blob:"), "Foto-Op mit Server-documentId gesendet");
ok((await store.getBlob(photoClient)) === null, "Foto-Blob nach erfolgreicher Übertragung lokal gelöscht");
ok(Object.keys(pass.idMap).length >= 11, "idMap für angelegte Datensätze erhalten");
const clientOpIds = queued.map((o) => o.clientOpId);
const stored = await prisma.syncOperation.findMany({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } });
ok(stored.length === 20 && stored.every((s) => s.status === "applied"), "20 SyncOperations serverseitig protokolliert (applied)");
const drift = Math.max(...stored.map((s) => Math.abs(s.clientCreatedAt.getTime() - new Date(queued.find((q) => q.clientOpId === s.clientOpId)!.clientCreatedAt).getTime())));
ok(drift < 1000 && stored.every((s) => s.clientCreatedAt.getTime() < Date.now() - 7 * 864e5), "alte Ops trotzdem angewendet, clientCreatedAt (10 Tage alt) protokolliert");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11, "11 Notizen angelegt");
ok((await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "Foto angehängt");
ok((await prisma.materialUsage.count({ where: { workOrderId: wo.id } })) === 2, "Material (Plan + Zusatz) erfasst");
ok((await prisma.checklistItem.findUniqueOrThrow({ where: { id: wo.checklistItems[0].id } })).checked, "Checkliste abgehakt");
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "ended" } })) === 1, "Session gestartet, pausiert, fortgesetzt, beendet");
console.log("\n— Wiederholung → duplicate —");
const replay = await applyOperations(f.ctxTech, { deviceId: "l7-e2e-device", operations: tech.stats.batches[0] });
ok(replay.results.length === 20 && replay.results.every((r) => r.status === "duplicate"), "gleicher Batch erneut → 20× duplicate");
// re-queue with the payloads as sent (the local photo blob was already released after applied)
const sentPayload = new Map(tech.stats.batches[0].map((o) => [o.clientOpId, o.payload]));
for (const e of afterPass) await store.putOp({ ...e, status: "queued", appliedAt: null, result: null, payload: sentPayload.get(e.clientOpId) ?? e.payload, blobRefs: [] });
const replayPass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
ok(replayPass.applied === 20 && (await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && o.result?.status === "duplicate"), "Outbox-Wiederholung: Server meldet duplicate, lokal applied");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11 && (await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "keine Doppelanlagen");
ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } })) === 20, "SyncOperations nicht doppelt");
console.log("\n— Konflikt stoppt keine unabhängige Op —");
const current = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
await prisma.workOrder.update({ where: { id: wo.id }, data: { version: { increment: 1 }, technicianNotes: "Im Büro geändert" } });
const conflictStore = createMemoryStore();
const tr = await enqueueOp(conflictStore, A, { opType: "work_order.transition", baseVersion: current.version, payload: { workOrderId: wo.id, to: "paused" } }, randomUUID());
const note = await enqueueOp(conflictStore, A, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Nach dem Konflikt" } }, randomUUID());
const cPass = await runSyncPass({ store: conflictStore, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
const cOps = new Map((await conflictStore.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o]));
ok(cPass.conflicts === 1 && cOps.get(tr.clientOpId)?.status === "conflict", "veraltete Statusänderung → conflict");
ok(cOps.get(note.clientOpId)?.status === "applied", "Notiz danach trotzdem applied");
const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
ok(afterConflict.status === current.status && afterConflict.technicianNotes === "Im Büro geändert", "Büro-Stand nicht überschrieben");
ok((await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: tr.clientOpId } }))?.status === "conflict", "Konflikt fürs Backoffice protokolliert");
console.log("\n— Mandantentrennung —");
const B = localCtx(f.ctxB);
const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo.id } });
const bNote = await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Mandant B" } }, randomUUID());
const bTransition = await enqueueOp(store, B, { opType: "work_order.transition", baseVersion: afterConflict.version, payload: { workOrderId: wo.id, to: "cancelled" } }, randomUUID());
const bBlob = randomUUID();
const bPhoto = await enqueueOp(store, B, { opType: "photo.attach", payload: { workOrderId: wo.id, documentId: await enqueueBlob(store, B, { clientId: bBlob, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "b.jpg" }) } }, randomUUID());
ok((await store.listOps(ctxKeyOf(A))).every((o) => o.tenantId === f.tenantA.id) && (await store.listOps(ctxKeyOf(B))).length === 3, "lokale Outbox je Mandant getrennt");
const bSrv = serverTransport(f.ctxB);
await runSyncPass({ store, ctx: B, transport: bSrv.transport, deviceId: "device-b" });
const bOps = new Map((await store.listOps(ctxKeyOf(B))).map((o) => [o.clientOpId, o]));
ok(bOps.get(bNote.clientOpId)?.status === "rejected" && bOps.get(bNote.clientOpId)?.lastError?.code === "not_found", "B: Notiz auf Auftrag von A → rejected not_found");
ok(bOps.get(bTransition.clientOpId)?.status === "rejected" && bOps.get(bTransition.clientOpId)?.lastError?.code === "not_found", "B: Statusänderung auf Auftrag von A → not_found (kein Versions-Leak)");
ok(bOps.get(bPhoto.clientOpId)?.status === "rejected" && (await store.getBlob(bBlob))?.status === "failed", "B: Foto-Upload auf Auftrag von A abgelehnt");
ok(bSrv.stats.batches.flat().every((o) => o.opType !== "photo.attach"), "B: Foto-Op ohne Upload nie gesendet");
const woAfterB = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === notesBefore && woAfterB.status !== "cancelled", "Auftrag von A unverändert");
ok((await store.listOps(ctxKeyOf(A))).length === 20, "Outbox von A durch B-Sync unberührt");
console.log("\n— Rollen/Scope —");
const O = localCtx(f.ctxOutsider);
const oStore = createMemoryStore();
const oNote = await enqueueOp(oStore, O, { opType: "note.create", payload: { workOrderId: wo.id, kind: "general", text: "ohne Zuweisung" } }, randomUUID());
const oSession = await enqueueOp(oStore, O, { opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID() } }, randomUUID());
await runSyncPass({ store: oStore, ctx: O, transport: serverTransport(f.ctxOutsider).transport, deviceId: "device-o" });
const oOps = new Map((await oStore.listOps(ctxKeyOf(O))).map((o) => [o.clientOpId, o]));
ok(oOps.get(oNote.clientOpId)?.lastError?.code === "not_found" && oOps.get(oSession.clientOpId)?.lastError?.code === "not_found", "Monteur ohne Zuweisung → not_found, endgültig (kein Retry)");
ok([...oOps.values()].every((o) => o.status === "rejected" && o.nextAttemptAt === null), "keine Wiederholungsschleife für Scope-Fehler");
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, userId: f.outsider.id } })) === 0, "keine Session für den fremden Monteur angelegt");
} finally {
await f.cleanup();
await closeJobQueues().catch(() => undefined);
await prisma.$disconnect();
}
console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});