From dd6a67d32915c2d0f5e6552a2014d1393c5259d3 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 19:23:14 +0200 Subject: [PATCH] Offline: Fotos bei abgelaufener Testphase nicht mehr verwerfen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload-Antwort 422 trial_expired gilt als vorübergehend: Datei bleibt auf dem Gerät, wartende Op bleibt in der Warteschlange, der Sync-Durchlauf stoppt mit Backoff. Co-Authored-By: Claude Opus 5 --- scripts/test-offline-core.ts | 13 ++++++++++++- src/lib/offline/outbox.ts | 12 +++++++++++- src/lib/offline/sync-engine.ts | 12 ++++++++---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/scripts/test-offline-core.ts b/scripts/test-offline-core.ts index 4a41e40..c0e1a03 100644 --- a/scripts/test-offline-core.ts +++ b/scripts/test-offline-core.ts @@ -43,7 +43,7 @@ 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" } = {}) { +function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" | "suspended" } = {}) { const batches: SyncOperationInput[][] = []; const uploads: string[] = []; const log: string[] = []; @@ -230,6 +230,17 @@ async function main() { 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 —"); diff --git a/src/lib/offline/outbox.ts b/src/lib/offline/outbox.ts index 6954390..f48e84a 100644 --- a/src/lib/offline/outbox.ts +++ b/src/lib/offline/outbox.ts @@ -31,7 +31,7 @@ export type OfflineState = { summary: OutboxSummary; lastSyncAt: string | null; lastPullAt: string | null; - lastError: null | "network" | "unauthorized" | "internal"; + lastError: null | "network" | "unauthorized" | "suspended" | "internal"; storage: StorageInfo; /** upload progress per blob client id (0–100) */ uploads: Record; @@ -88,6 +88,14 @@ async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineCont const uploadProgress = new Map void>(); +function isTrialExpired(body: string): boolean { + try { + return (JSON.parse(body) as { error?: { message?: string } }).error?.message === "trial_expired"; + } catch { + return false; + } +} + const browserTransport: Transport = { async sendBatch(device, operations) { let res: Response; @@ -141,6 +149,8 @@ const browserTransport: Transport = { return; } if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" }); + // expired trial (read-only tenant) → transient: keep the file on the device until extended/converted + if (xhr.status === 422 && isTrialExpired(xhr.responseText)) return resolve({ ok: false, error: "suspended" }); // 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" }); if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" }); diff --git a/src/lib/offline/sync-engine.ts b/src/lib/offline/sync-engine.ts index e070463..07d3a51 100644 --- a/src/lib/offline/sync-engine.ts +++ b/src/lib/offline/sync-engine.ts @@ -20,7 +20,8 @@ import { */ export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" }; -export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "invalid" | "forbidden" | "not_found" | "internal" }; +/** `suspended` = tenant temporarily read-only (expired trial) → transient, the file stays on the device. */ +export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "suspended" | "invalid" | "forbidden" | "not_found" | "internal" }; export type Transport = { sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise; @@ -48,7 +49,7 @@ export type PassResult = { uploaded: number; uploadFailed: number; /** transport-level stop reason of the pass */ - stopped: null | "network" | "unauthorized" | "internal"; + stopped: null | "network" | "unauthorized" | "suspended" | "internal"; /** client id → server id of this pass */ idMap: Record; }; @@ -123,7 +124,10 @@ export async function runSyncPass(deps: EngineDeps): Promise { continue; } const attempts = blob.attempts + 1; - const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error }; + const error: OutboxError = + up.error === "suspended" + ? { code: "blocked", message: "trial_expired" } + : { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error }; if (FINAL_UPLOAD_ERRORS.has(up.error)) { await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso }); res.uploadFailed++; @@ -134,7 +138,7 @@ export async function runSyncPass(deps: EngineDeps): Promise { } } else { await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso }); - if (up.error === "network" || up.error === "unauthorized") { + if (up.error === "network" || up.error === "unauthorized" || up.error === "suspended") { res.stopped = up.error; return res; }