// 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" | "suspended" } = {}) { 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 => ({ clientOpId: op.clientOpId, status: "applied", ...extra }); function order(id: string, extra: Partial = {}): 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:"); 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"); // L15: expired trial (read-only tenant) must not discard photos const store5 = createMemoryStore(); const c5 = randomUUID(); const op5 = await enqueueOp(store5, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store5, A, { clientId: c5, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1)); const suspended = fakeTransport((o) => applied(o), { uploadError: "suspended" }); const p5 = await runSyncPass({ store: store5, ctx: A, transport: suspended.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); const blob5 = await store5.getBlob(c5); const op5After = (await store5.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === op5.clientOpId); ok(p5.stopped === "suspended" && p5.rejected === 0 && suspended.batches.length === 0, "Testphase abgelaufen → Pass stoppt, nichts verworfen"); ok(!!blob5 && blob5.nextAttemptAt !== null && blob5.lastError?.message === "trial_expired" && op5After?.status === "queued", "Foto bleibt auf dem Gerät, Op wartet (Backoff)"); } 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, 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); });