Merge lane/offline in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
// Lane L7 „Offline & PWA" — reine Outbox-/Bundle-Logik ohne IndexedDB und ohne Server:
|
||||
// Reihenfolge je Auftrag, Retry/Backoff, idMap-Anwendung, Blob-Upload vor der Op, Konflikt stoppt
|
||||
// keine unabhängigen Ops, verkettete baseVersion, Mandanten-/User-Trennung der lokalen Stores,
|
||||
// optimistische Auftragsansicht, Vorab-Download-Auswahl, Dokument-Cache (LRU), Service-Worker-Regeln.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-offline-core.ts (keine Infrastruktur nötig)
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SyncOperationInput, SyncOpResult } from "../src/lib/sync/envelope";
|
||||
import { createMemoryStore } from "../src/lib/offline/memory-store";
|
||||
import {
|
||||
applyIdMapToPayload,
|
||||
applyResults,
|
||||
backoffMs,
|
||||
BACKOFF_MAX_MS,
|
||||
blobRef,
|
||||
canDiscard,
|
||||
createEntry,
|
||||
MAX_BATCH,
|
||||
problemKey,
|
||||
prunable,
|
||||
retryEntry,
|
||||
selectBatch,
|
||||
summarize,
|
||||
} from "../src/lib/offline/outbox-core";
|
||||
import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine";
|
||||
import { buildOrderView, isBundleStale, parseMaxDays, selectOfflineOrders, toBundleRecords } from "../src/lib/offline/bundle-core";
|
||||
import { DOC_CACHE, DOC_CACHE_MAX_BYTES, PAGE_CACHE, selectEvictions, selectOfflineDocuments } from "../src/lib/offline/doc-cache";
|
||||
import { ctxKeyOf, type BundleOrderData, type OutboxEntry } from "../src/lib/offline/types";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const A = { tenantId: "tenant-a", userId: "user-1" };
|
||||
const A2 = { tenantId: "tenant-a", userId: "user-2" };
|
||||
const B = { tenantId: "tenant-b", userId: "user-1" };
|
||||
const T0 = new Date("2026-09-14T08:00:00.000Z");
|
||||
const at = (ms: number) => new Date(T0.getTime() + ms);
|
||||
const fixedRandom = () => 1; // backoff = full step
|
||||
|
||||
/** Fake server: records every batch, answers per op via `decide`. */
|
||||
function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) {
|
||||
const batches: SyncOperationInput[][] = [];
|
||||
const uploads: string[] = [];
|
||||
const log: string[] = [];
|
||||
let failBatches = opts.failBatches ?? 0;
|
||||
const transport: Transport = {
|
||||
async sendBatch(_device, operations) {
|
||||
if (failBatches > 0) {
|
||||
failBatches--;
|
||||
log.push("batch:network");
|
||||
return { ok: false, error: "network" };
|
||||
}
|
||||
batches.push(operations);
|
||||
log.push(...operations.map((o) => `op:${o.opType}`));
|
||||
return { ok: true, response: { results: operations.map(decide), serverTime: new Date().toISOString() } };
|
||||
},
|
||||
async upload(blob) {
|
||||
log.push(`upload:${blob.clientId}`);
|
||||
if (opts.uploadError) return { ok: false, error: opts.uploadError };
|
||||
uploads.push(blob.clientId);
|
||||
return { ok: true, documentId: `doc-${blob.clientId.slice(0, 8)}` };
|
||||
},
|
||||
};
|
||||
return { transport, batches, uploads, log };
|
||||
}
|
||||
|
||||
const applied = (op: SyncOperationInput, extra: Partial<SyncOpResult> = {}): SyncOpResult => ({ clientOpId: op.clientOpId, status: "applied", ...extra });
|
||||
|
||||
function order(id: string, extra: Partial<BundleOrderData> = {}): BundleOrderData {
|
||||
return {
|
||||
id,
|
||||
number: `A-${id}`,
|
||||
title: `Auftrag ${id}`,
|
||||
status: "assigned",
|
||||
statusGroup: "planned",
|
||||
priority: "normal",
|
||||
isEmergency: false,
|
||||
plannedStart: null,
|
||||
plannedEnd: null,
|
||||
version: 3,
|
||||
customer: { companyName: "Kunde", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null },
|
||||
checklistItems: [{ id: "chk-1", label: "Absichern", required: true, requiresPhoto: false, checked: false, checkedAt: null, comment: null }],
|
||||
photoRequirements: [{ id: "req-1", key: "typ", label: "Typenschild", _count: { photos: 0 } }],
|
||||
materialPlans: [],
|
||||
materialUsages: [],
|
||||
documents: [],
|
||||
siteHistory: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n— Reihenfolge je Auftrag / Batch —");
|
||||
{
|
||||
const store = createMemoryStore();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const e = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", clientId: randomUUID(), kind: "general", text: `n${i}` } }, randomUUID(), at(i));
|
||||
ids.push(e.clientOpId);
|
||||
}
|
||||
await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-2", clientId: randomUUID(), kind: "general", text: "x" } }, randomUUID(), at(10));
|
||||
const fake = fakeTransport((op) => applied(op));
|
||||
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(100) });
|
||||
const sentIds = fake.batches.flat().map((o) => o.clientOpId);
|
||||
ok(pass.applied === 4 && fake.batches.length === 1, "4 Ops in einem Batch übertragen und angewendet");
|
||||
ok(JSON.stringify(sentIds.slice(0, 3)) === JSON.stringify(ids), "Ops eines Auftrags in Erfassungsreihenfolge gesendet");
|
||||
ok((await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && !!o.appliedAt), "lokaler Status applied mit Zeitstempel");
|
||||
}
|
||||
{
|
||||
const ops: OutboxEntry[] = [];
|
||||
for (let i = 0; i < 120; i++) ops.push(createEntry(A, { opType: "note.create", payload: { workOrderId: `wo-${i % 7}`, kind: "general", text: "t" } }, { clientOpId: randomUUID(), now: at(i), existing: [], seq: i }));
|
||||
const b = selectBatch(ops, [], at(1000));
|
||||
ok(b.entries.length === MAX_BATCH && b.operations.length === MAX_BATCH, `Batch auf ${MAX_BATCH} Ops begrenzt`);
|
||||
const store = createMemoryStore();
|
||||
for (const o of ops) await store.putOp(o);
|
||||
const fake = fakeTransport((op) => applied(op));
|
||||
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(2000) });
|
||||
ok(pass.applied === 120 && fake.batches.length === 3 && fake.batches.every((x) => x.length <= MAX_BATCH), "120 Ops → 3 Batches (50/50/20)");
|
||||
}
|
||||
|
||||
console.log("\n— Retry / Backoff —");
|
||||
{
|
||||
ok(backoffMs(1, fixedRandom) === 2000 && backoffMs(2, fixedRandom) === 4000 && backoffMs(4, fixedRandom) === 16000, "Backoff verdoppelt sich (2 s, 4 s, 16 s)");
|
||||
ok(backoffMs(30, fixedRandom) === BACKOFF_MAX_MS, "Backoff gedeckelt auf 5 min");
|
||||
ok(backoffMs(3, () => 0) === 4000, "Jitter halbiert höchstens");
|
||||
|
||||
const store = createMemoryStore();
|
||||
const e1 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
|
||||
const e2 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, randomUUID(), at(1));
|
||||
const fake = fakeTransport((op) => applied(op), { failBatches: 1 });
|
||||
const p1 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
|
||||
let ops = await store.listOps(ctxKeyOf(A));
|
||||
ok(p1.stopped === "network" && ops.every((o) => o.status === "queued" && o.attempts === 1 && o.lastError?.code === "network"), "Netzfehler → zurück in die Warteschlange, Versuch gezählt, Fehler gespeichert");
|
||||
ok(ops.every((o) => o.nextAttemptAt === at(10 + 2000).toISOString()), "nächster Versuch nach Backoff terminiert");
|
||||
const p2 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(500), random: fixedRandom });
|
||||
ok(p2.sent === 0 && fake.batches.length === 0, "während Backoff wird nicht gesendet");
|
||||
const p3 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(3000), random: fixedRandom });
|
||||
ops = await store.listOps(ctxKeyOf(A));
|
||||
ok(p3.applied === 2 && ops.every((o) => o.status === "applied"), "nach Backoff erfolgreich übertragen");
|
||||
ok(fake.batches[0].map((o) => o.clientOpId).join() === [e1.clientOpId, e2.clientOpId].join(), "gleiche clientOpIds beim Wiederholen (Idempotenz)");
|
||||
|
||||
// server-side transient error per op
|
||||
const store2 = createMemoryStore();
|
||||
await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
|
||||
const fake2 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "internal" }));
|
||||
await runSyncPass({ store: store2, ctx: A, transport: fake2.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
|
||||
const [t] = await store2.listOps(ctxKeyOf(A));
|
||||
ok(t.status === "queued" && t.attempts === 1 && !!t.nextAttemptAt, "rejected internal → transient, erneuter Versuch geplant");
|
||||
const store3 = createMemoryStore();
|
||||
await enqueueOp(store3, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
|
||||
const fake3 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "not_found" }));
|
||||
await runSyncPass({ store: store3, ctx: A, transport: fake3.transport, deviceId: "d", now: () => at(10) });
|
||||
const [r] = await store3.listOps(ctxKeyOf(A));
|
||||
ok(r.status === "rejected" && r.lastError?.code === "not_found" && canDiscard(r) && problemKey(r) === "notFound", "rejected not_found → endgültig, verwerfbar, Klartext-Schlüssel");
|
||||
const retried = retryEntry(r, at(20), "new-op-id");
|
||||
ok(retried?.status === "queued" && retried.clientOpId === "new-op-id" && retried.attempts === 0, "erneut versuchen: neue clientOpId (Server hat altes Ergebnis gespeichert)");
|
||||
const unauthorized = fakeTransport((op) => applied(op));
|
||||
unauthorized.transport.sendBatch = async () => ({ ok: false, error: "unauthorized" });
|
||||
const store4 = createMemoryStore();
|
||||
await enqueueOp(store4, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
|
||||
const p4 = await runSyncPass({ store: store4, ctx: A, transport: unauthorized.transport, deviceId: "d", now: () => at(10) });
|
||||
ok(p4.stopped === "unauthorized" && summarize(await store4.listOps(ctxKeyOf(A)), []).waitingForAuth, "401 → Pass stoppt, Hinweis „neu anmelden“");
|
||||
}
|
||||
|
||||
console.log("\n— Konflikt stoppt keine unabhängigen Ops —");
|
||||
{
|
||||
const store = createMemoryStore();
|
||||
const tr = await enqueueOp(store, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0));
|
||||
const sameOrderNote = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "danach" } }, randomUUID(), at(1));
|
||||
const other = await enqueueOp(store, A, { opType: "checklist.toggle", payload: { workOrderId: "wo-2", itemId: "c", checked: true } }, randomUUID(), at(2));
|
||||
const fake = fakeTransport((op) => (op.opType === "work_order.transition" ? { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict", entityVersion: 5 } : applied(op)));
|
||||
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
|
||||
const byId = new Map((await store.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o]));
|
||||
ok(pass.conflicts === 1 && byId.get(tr.clientOpId)?.status === "conflict", "Statusänderung → conflict (nicht überschrieben)");
|
||||
ok(byId.get(sameOrderNote.clientOpId)?.status === "applied" && byId.get(other.clientOpId)?.status === "applied", "additive Op desselben Auftrags und Op eines anderen Auftrags trotzdem angewendet");
|
||||
ok(problemKey(byId.get(tr.clientOpId)!) === "conflictTransition" && !canDiscard(byId.get(tr.clientOpId)!), "Konflikt: Klartext „im Büro geändert“, nicht verwerfbar");
|
||||
ok(summarize([...byId.values()], []).conflicts === 1, "Konflikt in der Zusammenfassung gezählt");
|
||||
// duplicate of a stored conflict stays a conflict
|
||||
const dupStore = createMemoryStore();
|
||||
await enqueueOp(dupStore, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0));
|
||||
const dup = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "duplicate", errorCode: "conflict", message: "original status: conflict" }));
|
||||
await runSyncPass({ store: dupStore, ctx: A, transport: dup.transport, deviceId: "d", now: () => at(10) });
|
||||
ok((await dupStore.listOps(ctxKeyOf(A)))[0].status === "conflict", "duplicate eines gespeicherten Konflikts bleibt conflict");
|
||||
}
|
||||
{
|
||||
// pending op (backoff) blocks only later ops of the same order
|
||||
const e1 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, { clientOpId: "a1", now: T0, existing: [], seq: 1 });
|
||||
const e2 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, { clientOpId: "a2", now: T0, existing: [], seq: 2 });
|
||||
const e3 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-2", kind: "general", text: "c" } }, { clientOpId: "b1", now: T0, existing: [], seq: 3 });
|
||||
const b = selectBatch([{ ...e1, nextAttemptAt: at(60_000).toISOString(), attempts: 1 }, e2, e3], [], at(1000));
|
||||
ok(b.entries.map((e) => e.clientOpId).join() === "b1", "Op im Backoff blockiert nur Folge-Ops desselben Auftrags");
|
||||
}
|
||||
|
||||
console.log("\n— Blob-Upload vor der Op —");
|
||||
{
|
||||
const store = createMemoryStore();
|
||||
const clientId = randomUUID();
|
||||
const ref = await enqueueBlob(store, A, { clientId, workOrderId: "wo-1", kind: "photo", blob: new Blob([new Uint8Array(1024)], { type: "image/jpeg" }), fileName: "f.jpg" }, at(0));
|
||||
ok(ref === blobRef(clientId), "Blob-Referenz blob:<clientId>");
|
||||
const op = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: randomUUID(), documentId: ref, phase: "during" } }, randomUUID(), at(1));
|
||||
ok(op.blobRefs.length === 1 && (await store.getBlob(clientId))?.opClientOpId === op.clientOpId, "Op mit Blob verknüpft");
|
||||
ok(selectBatch(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A)), at(2)).entries.length === 0, "ohne Upload wird die Op nicht gesendet");
|
||||
ok(summarize(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A))).pendingUploads === 1, "ausstehender Upload gezählt");
|
||||
const fake = fakeTransport((o) => applied(o));
|
||||
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
|
||||
ok(fake.log.indexOf(`upload:${clientId}`) === 0 && fake.log.indexOf("op:photo.attach") === 1, "Upload vor photo.attach");
|
||||
ok(fake.batches[0][0].payload.documentId === `doc-${clientId.slice(0, 8)}`, "Payload auf Server-documentId umgeschrieben");
|
||||
ok(pass.applied === 1 && (await store.getBlob(clientId)) === null, "nach applied: Blob lokal freigegeben");
|
||||
|
||||
const store2 = createMemoryStore();
|
||||
const c2 = randomUUID();
|
||||
const ref2 = await enqueueBlob(store2, A, { clientId: c2, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f.jpg" }, at(0));
|
||||
await enqueueOp(store2, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: ref2 } }, randomUUID(), at(1));
|
||||
const note = await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-9", kind: "general", text: "unabhängig" } }, randomUUID(), at(2));
|
||||
const bad = fakeTransport((o) => applied(o), { uploadError: "invalid" });
|
||||
await runSyncPass({ store: store2, ctx: A, transport: bad.transport, deviceId: "d", now: () => at(10) });
|
||||
const ops2 = await store2.listOps(ctxKeyOf(A));
|
||||
const photoOp = ops2.find((o) => o.opType === "photo.attach")!;
|
||||
ok(photoOp.status === "rejected" && problemKey(photoOp) === "upload", "Upload endgültig abgelehnt → Op rejected mit Klartext");
|
||||
ok(ops2.find((o) => o.clientOpId === note.clientOpId)?.status === "applied", "unabhängige Op trotzdem gesendet");
|
||||
const store3 = createMemoryStore();
|
||||
const c3 = randomUUID();
|
||||
await enqueueOp(store3, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store3, A, { clientId: c3, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1));
|
||||
const offline = fakeTransport((o) => applied(o), { uploadError: "network" });
|
||||
const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
|
||||
const blob3 = await store3.getBlob(c3);
|
||||
ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet");
|
||||
}
|
||||
|
||||
console.log("\n— idMap / verkettete baseVersion —");
|
||||
{
|
||||
ok(
|
||||
JSON.stringify(applyIdMapToPayload({ clientId: "c1", photoId: "c1", nested: { ref: "c1" }, list: ["c1", "x"] }, { c1: "srv-1" })) ===
|
||||
JSON.stringify({ clientId: "c1", photoId: "srv-1", nested: { ref: "srv-1" }, list: ["srv-1", "x"] }),
|
||||
"idMap ersetzt Referenzen, eigenes clientId-Feld bleibt",
|
||||
);
|
||||
const store = createMemoryStore();
|
||||
const photoClient = randomUUID();
|
||||
const first = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: photoClient, documentId: "doc-1" } }, randomUUID(), at(0));
|
||||
const second = await enqueueOp(store, A, { opType: "material.upsert", payload: { workOrderId: "wo-1", clientId: randomUUID(), name: "Rohr", quantity: 1, unit: "m", usageStatus: "additional", photoId: photoClient } }, randomUUID(), at(1));
|
||||
const ops = await store.listOps(ctxKeyOf(A));
|
||||
const { changed, idMap } = applyResults(ops, [ops[0]], [{ clientOpId: first.clientOpId, status: "applied", idMap: { [photoClient]: "photo-srv" } }], at(5));
|
||||
const rewritten = changed.find((c) => c.clientOpId === second.clientOpId);
|
||||
ok(idMap[photoClient] === "photo-srv" && rewritten?.payload.photoId === "photo-srv", "idMap auf wartende Ops angewendet (client id → server id)");
|
||||
|
||||
const chain = createMemoryStore();
|
||||
const start = await enqueueOp(chain, A, { opType: "session.start", payload: { workOrderId: "wo-1", mode: "work", clientId: randomUUID() } }, randomUUID(), at(0));
|
||||
const complete = await enqueueOp(chain, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "technically_completed" } }, randomUUID(), at(1));
|
||||
ok(complete.chainedBase === true, "Statuswechsel hinter eigener Session-Op → baseVersion verkettet");
|
||||
const fake = fakeTransport((op) => (op.opType === "session.start" ? applied(op, { entityVersion: 5 }) : op.baseVersion === 5 ? applied(op, { entityVersion: 6 }) : { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict" }));
|
||||
await runSyncPass({ store: chain, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), maxBatch: 1 });
|
||||
const chained = (await chain.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === complete.clientOpId);
|
||||
ok(fake.batches.length === 2 && fake.batches[1][0].baseVersion === 5 && chained?.status === "applied", "baseVersion aus Server-Ergebnis der Vorgänger-Op übernommen");
|
||||
ok(start.chainedBase === undefined, "additive/Session-Op ohne Verkettung");
|
||||
}
|
||||
|
||||
console.log("\n— Mandanten-/User-Trennung der lokalen Stores —");
|
||||
{
|
||||
const store = createMemoryStore();
|
||||
await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A" } }, randomUUID(), at(0));
|
||||
await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "B" } }, randomUUID(), at(1));
|
||||
await enqueueOp(store, A2, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A2" } }, randomUUID(), at(2));
|
||||
await enqueueBlob(store, B, { clientId: randomUUID(), workOrderId: "wo-1", kind: "photo", blob: new Blob(["b"]), fileName: "b" }, at(3));
|
||||
await store.replaceBundle(ctxKeyOf(A), toBundleRecords(ctxKeyOf(A), [order("wo-1")], at(0).toISOString()));
|
||||
await store.replaceBundle(ctxKeyOf(B), toBundleRecords(ctxKeyOf(B), [order("wo-b")], at(0).toISOString()));
|
||||
await store.setMeta(ctxKeyOf(A), "draft:note:wo-1", { text: "geheim A" });
|
||||
const opsA = await store.listOps(ctxKeyOf(A));
|
||||
ok(opsA.length === 1 && opsA[0].payload.text === "A" && opsA[0].tenantId === "tenant-a" && opsA[0].userId === "user-1", "Outbox liefert nur Ops des eigenen Mandanten+Users");
|
||||
ok((await store.listOps(ctxKeyOf(A2))).length === 1 && (await store.listOps(ctxKeyOf(B)))[0].payload.text === "B", "anderer User desselben Mandanten / anderer Mandant getrennt");
|
||||
ok((await store.listBlobs(ctxKeyOf(A))).length === 0 && (await store.listBlobs(ctxKeyOf(B))).length === 1, "Blobs je Kontext getrennt");
|
||||
ok((await store.listBundle(ctxKeyOf(B))).map((r) => r.workOrderId).join() === "wo-b", "Bundle je Kontext getrennt");
|
||||
ok((await store.getMeta(ctxKeyOf(B), "draft:note:wo-1")) === null, "Entwürfe je Kontext getrennt");
|
||||
await store.replaceBundle(ctxKeyOf(A), [...toBundleRecords(ctxKeyOf(B), [order("wo-injected")], T0.toISOString()), ...toBundleRecords(ctxKeyOf(A), [order("wo-1")], T0.toISOString())]);
|
||||
ok((await store.listBundle(ctxKeyOf(B))).every((r) => r.workOrderId === "wo-b") && (await store.listBundle(ctxKeyOf(A))).length === 1, "replaceBundle schreibt keine Datensätze fremder Kontexte");
|
||||
await store.clearContext(ctxKeyOf(A));
|
||||
ok((await store.listOps(ctxKeyOf(A))).length === 0 && (await store.listBundle(ctxKeyOf(A))).length === 0 && (await store.getMeta(ctxKeyOf(A), "draft:note:wo-1")) === null, "Kontext A gelöscht (Logout)");
|
||||
ok((await store.listOps(ctxKeyOf(B))).length === 1 && (await store.listOps(ctxKeyOf(A2))).length === 1, "Daten anderer Kontexte bleiben erhalten");
|
||||
ok(!(await store.listContexts()).includes(ctxKeyOf(A)), "Kontext A nicht mehr gelistet");
|
||||
// a pass for context A never sends ops of B
|
||||
const fake = fakeTransport((o) => applied(o));
|
||||
await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
|
||||
ok(fake.batches.length === 0, "Sync-Pass von A sendet keine Ops von B/A2");
|
||||
}
|
||||
|
||||
console.log("\n— Optimistische Auftragsansicht —");
|
||||
{
|
||||
const record = toBundleRecords("tenant-a:user-1", [order("wo-1", { status: "accepted" })], at(0).toISOString())[0];
|
||||
const mk = (opType: OutboxEntry["opType"], payload: Record<string, unknown>, seq: number, status: OutboxEntry["status"] = "queued") => ({
|
||||
...createEntry(A, { opType, payload }, { clientOpId: `op-${seq}`, now: at(seq * 1000), existing: [], seq }),
|
||||
status,
|
||||
...(status === "applied" ? { appliedAt: at(seq * 1000).toISOString() } : {}),
|
||||
});
|
||||
const ops = [
|
||||
mk("session.start", { workOrderId: "wo-1", mode: "work" }, 1),
|
||||
mk("note.create", { workOrderId: "wo-1", clientId: "n-1", kind: "work_done", text: "Montiert" }, 2),
|
||||
mk("checklist.toggle", { workOrderId: "wo-1", itemId: "chk-1", checked: true }, 3),
|
||||
mk("photo.attach", { workOrderId: "wo-1", clientId: "p-1", documentId: blobRef("b-1"), photoRequirementId: "req-1" }, 4),
|
||||
mk("session.pause", { workOrderId: "wo-1" }, 5),
|
||||
mk("note.create", { workOrderId: "wo-1", kind: "general", text: "abgelehnt" }, 6, "rejected"),
|
||||
mk("note.create", { workOrderId: "wo-2", kind: "general", text: "anderer Auftrag" }, 7),
|
||||
];
|
||||
const view = buildOrderView(record, ops);
|
||||
ok(view.status === "paused" && view.local.session === "paused" && view.statusGroup === "in_progress", "Zeiten/Status optimistisch angewendet");
|
||||
ok(view.local.notes.length === 1 && view.local.notes[0].text === "Montiert" && view.local.notes[0].pending, "Notiz lokal sichtbar (ausstehend), abgelehnte nicht");
|
||||
ok(view.checklistItems[0].checked && view.photoRequirements[0]._count.photos === 1 && view.local.photos[0].blobClientId === "b-1", "Checkliste und Pflichtfoto lokal aktualisiert");
|
||||
ok(view.local.pendingOps === 5 && view.local.rejected, "ausstehende Ops gezählt, Fehlerhinweis gesetzt");
|
||||
ok(record.data.status === "accepted" && !record.data.checklistItems[0].checked, "Server-Snapshot unverändert");
|
||||
const afterPull = buildOrderView({ ...record, syncedAt: at(60_000).toISOString() }, ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })));
|
||||
ok(afterPull.status === "accepted" && afterPull.local.notes.length === 0, "nach neuem Pull gilt der Server-Stand");
|
||||
ok(prunable(ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })), at(60_000).toISOString()).length === ops.length, "angewendete Ops nach Pull aufräumbar");
|
||||
}
|
||||
|
||||
console.log("\n— Vorab-Download / Veraltet / Dokument-Cache —");
|
||||
{
|
||||
const now = new Date("2026-09-14T10:00:00+02:00");
|
||||
const d = (days: number, h = 9) => {
|
||||
const x = new Date(now);
|
||||
x.setHours(h, 0, 0, 0);
|
||||
x.setDate(x.getDate() + days);
|
||||
return x.toISOString();
|
||||
};
|
||||
const orders = [
|
||||
order("today", { plannedStart: d(0), plannedEnd: d(0, 17) }),
|
||||
order("in3", { plannedStart: d(3) }),
|
||||
order("in5", { plannedStart: d(5) }),
|
||||
order("running", { status: "in_progress", plannedStart: d(-10) }),
|
||||
order("past-planned", { plannedStart: d(-2), plannedEnd: d(-2, 12) }),
|
||||
order("undated"),
|
||||
];
|
||||
const sel = selectOfflineOrders(orders, now).map((o) => o.id);
|
||||
ok(sel.join() === "today,in3,running", "heute + 3 Tage + laufende Aufträge ausgewählt");
|
||||
ok(!isBundleStale(new Date(now.getTime() - 6 * 864e5).toISOString(), now, 7) && isBundleStale(new Date(now.getTime() - 8 * 864e5).toISOString(), now, 7), "Bundle nach OFFLINE_MAX_DAYS als veraltet markiert");
|
||||
ok(parseMaxDays(undefined) === 7 && parseMaxDays("14") === 14 && parseMaxDays("0") === 7 && parseMaxDays("abc") === 7, "OFFLINE_MAX_DAYS mit Default 7");
|
||||
const docs = selectOfflineDocuments([
|
||||
{ id: "1", category: "technical_drawing", fileSize: 1e6 },
|
||||
{ id: "2", category: "assembly_instructions", fileSize: 25 * 1024 * 1024 },
|
||||
{ id: "3", category: "safety_document", fileSize: 26 * 1024 * 1024 },
|
||||
{ id: "4", category: "order_confirmation", fileSize: 1e5 },
|
||||
{ id: "1", category: "technical_drawing", fileSize: 1e6 },
|
||||
]);
|
||||
ok(docs.map((x) => x.id).join() === "1,2", "Zeichnung/Montageanleitung/Sicherheit ≤ 25 MB, ohne Dubletten");
|
||||
const MB = 1024 * 1024;
|
||||
const ev = selectEvictions(
|
||||
[
|
||||
{ url: "a", size: 100 * MB, lastUsed: 1 },
|
||||
{ url: "b", size: 150 * MB, lastUsed: 3 },
|
||||
{ url: "c", size: 40 * MB, lastUsed: 2 },
|
||||
],
|
||||
DOC_CACHE_MAX_BYTES,
|
||||
20 * MB,
|
||||
);
|
||||
ok(ev.join() === "a", "LRU: ältester Eintrag verdrängt, bis 300 MB passen");
|
||||
ok(selectEvictions([{ url: "a", size: 10, lastUsed: 1 }], DOC_CACHE_MAX_BYTES).length === 0, "unter dem Limit keine Verdrängung");
|
||||
ok(selectEvictions([{ url: "keep", size: 200 * MB, lastUsed: 1 }, { url: "old", size: 200 * MB, lastUsed: 5 }], DOC_CACHE_MAX_BYTES, 0, new Set(["keep"])).join() === "old", "Dokumente des aktuellen Bundles zuletzt verdrängt");
|
||||
}
|
||||
|
||||
console.log("\n— Service Worker (public/sw.js) —");
|
||||
{
|
||||
const sw = readFileSync(new URL("../public/sw.js", import.meta.url), "utf8");
|
||||
ok(sw.includes(`"${DOC_CACHE}"`) && sw.includes(`"${PAGE_CACHE}"`) && sw.includes(String(DOC_CACHE_MAX_BYTES / 1024 / 1024)), "Cache-Namen/Limit in sw.js und doc-cache.ts identisch");
|
||||
ok(/request\.method !== "GET"/.test(sw), "nur GET-Anfragen werden behandelt (keine Mutationen)");
|
||||
ok(/NEVER_CACHE/.test(sw) && sw.includes('"/api/v1/sync"') && sw.includes('"/api/auth"') && sw.includes('"/login"'), "Sync-API, Auth-Routen und Login ausgeschlossen");
|
||||
ok(sw.includes('"/m/offline"') && sw.includes("/_next/static/"), "Offline-Fallback und statische Assets berücksichtigt");
|
||||
ok(sw.includes("SKIP_WAITING"), "Update-Flow über SKIP_WAITING");
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user