L7 Offline & PWA: E2E-Test (20 Ops offline → ein Batch → applied/duplicate, Mandanten-/Scope-Trennung), Message-Keys ohne Punkt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+14
-14
@@ -56,20 +56,20 @@
|
||||
"resetDone": "Lokale Daten gelöscht."
|
||||
},
|
||||
"op": {
|
||||
"session.start": "Einsatz gestartet",
|
||||
"session.pause": "Pause",
|
||||
"session.resume": "Weiter",
|
||||
"session.end": "Zeiterfassung beendet",
|
||||
"work_order.transition": "Statusänderung",
|
||||
"note.create": "Notiz",
|
||||
"checklist.toggle": "Checkliste",
|
||||
"material.upsert": "Material",
|
||||
"photo.attach": "Foto",
|
||||
"voice.attach": "Sprachnotiz",
|
||||
"report.save_draft": "Berichtsentwurf",
|
||||
"report.submit": "Bericht",
|
||||
"signature.capture": "Unterschrift",
|
||||
"emergency.create": "Notdiensteinsatz"
|
||||
"session_start": "Einsatz gestartet",
|
||||
"session_pause": "Pause",
|
||||
"session_resume": "Weiter",
|
||||
"session_end": "Zeiterfassung beendet",
|
||||
"work_order_transition": "Statusänderung",
|
||||
"note_create": "Notiz",
|
||||
"checklist_toggle": "Checkliste",
|
||||
"material_upsert": "Material",
|
||||
"photo_attach": "Foto",
|
||||
"voice_attach": "Sprachnotiz",
|
||||
"report_save_draft": "Berichtsentwurf",
|
||||
"report_submit": "Bericht",
|
||||
"signature_capture": "Unterschrift",
|
||||
"emergency_create": "Notdiensteinsatz"
|
||||
},
|
||||
"state": {
|
||||
"queued": "Wartet",
|
||||
|
||||
+14
-14
@@ -56,20 +56,20 @@
|
||||
"resetDone": "Local data deleted."
|
||||
},
|
||||
"op": {
|
||||
"session.start": "Job started",
|
||||
"session.pause": "Break",
|
||||
"session.resume": "Resume",
|
||||
"session.end": "Time tracking ended",
|
||||
"work_order.transition": "Status change",
|
||||
"note.create": "Note",
|
||||
"checklist.toggle": "Checklist",
|
||||
"material.upsert": "Material",
|
||||
"photo.attach": "Photo",
|
||||
"voice.attach": "Voice note",
|
||||
"report.save_draft": "Report draft",
|
||||
"report.submit": "Report",
|
||||
"signature.capture": "Signature",
|
||||
"emergency.create": "Emergency job"
|
||||
"session_start": "Job started",
|
||||
"session_pause": "Break",
|
||||
"session_resume": "Resume",
|
||||
"session_end": "Time tracking ended",
|
||||
"work_order_transition": "Status change",
|
||||
"note_create": "Note",
|
||||
"checklist_toggle": "Checklist",
|
||||
"material_upsert": "Material",
|
||||
"photo_attach": "Photo",
|
||||
"voice_attach": "Voice note",
|
||||
"report_save_draft": "Report draft",
|
||||
"report_submit": "Report",
|
||||
"signature_capture": "Signature",
|
||||
"emergency_create": "Emergency job"
|
||||
},
|
||||
"state": {
|
||||
"queued": "Waiting",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -145,7 +145,7 @@ export function SyncPanel() {
|
||||
<li key={op.clientOpId} className={cn(op.status === "conflict" ? noticeWarn : noticeError, "flex-col gap-2")}>
|
||||
<p className="flex items-center gap-2 font-semibold text-foreground">
|
||||
<TriangleAlert className="size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||
{t(`op.${op.opType}`)} · {t(`state.${op.status}`)}
|
||||
{t(`op.${op.opType.replace(".", "_")}`)} · {t(`state.${op.status}`)}
|
||||
</p>
|
||||
<p className="text-foreground">{t(`problem.${problemKey(op)}`)}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{t("sync.createdAt", { time: fmtDateTimeShort(op.clientCreatedAt, locale) })}</p>
|
||||
@@ -273,7 +273,7 @@ function QueueRow({ op, locale }: { op: OutboxEntry; locale: string }) {
|
||||
return (
|
||||
<li className="space-y-0.5 py-2.5 text-[15px]">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-semibold">{t(`op.${op.opType}`)}</span>
|
||||
<span className="font-semibold">{t(`op.${op.opType.replace(".", "_")}`)}</span>
|
||||
<span className="text-[13px] font-semibold text-muted-foreground">{t(`state.${op.status}`)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user