import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest"; import { readHandOvers, rememberHandOver } from "@/lib/photos/handover"; vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn() })); vi.mock("@/lib/treatments/api", () => ({ createTreatment: vi.fn() })); vi.mock("@/lib/documents/api", () => ({ uploadDocument: vi.fn() })); vi.mock("@/lib/phenology/api", () => ({ createPhenologyReport: vi.fn() })); vi.mock("@/lib/hive/api", () => ({ postHiveChange: vi.fn() })); vi.mock("@/lib/voice/api", () => ({ uploadVoiceCapture: vi.fn() })); vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() })); // What the inspection drain hands over to the photos, seen at that moment. const photoHandOver = vi.hoisted(() => ({ calls: [] as { key: string; inspectionId: string; entryStillQueued: boolean }[], failFor: null as string | null, // Set per test: reads the queue of the test's fresh database. isQueued: (() => Promise.resolve(false)) as (key: string) => Promise, })); vi.mock("@/lib/photos/store", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveQueuedInspection: async (key: string, inspectionId: string) => { photoHandOver.calls.push({ key, inspectionId, entryStillQueued: await photoHandOver.isQueued(key), }); if (photoHandOver.failFor === key) { throw new Error("Speicher voll"); } return actual.resolveQueuedInspection(key, inspectionId); }, }; }); type OfflineDbModule = typeof import("./db"); type SyncModule = typeof import("./sync"); type PhotoStoreModule = typeof import("@/lib/photos/store"); type ApiErrorClass = typeof import("@/lib/auth/api-client").ApiError; let db: OfflineDbModule; let sync: SyncModule; let photos: PhotoStoreModule; let ApiError: ApiErrorClass; let createInspection: MockedFunction; let createTreatment: MockedFunction; let createPhenologyReport: MockedFunction< typeof import("@/lib/phenology/api").createPhenologyReport >; let uploadDocument: MockedFunction; let refreshPacksAfterSync: MockedFunction< typeof import("@/lib/apiaries/offline-pack").refreshPacksAfterSync >; function inspection(colony: number) { return { colonyId: `colony-${colony}`, date: "2026-10-01T00:00:00.000Z" }; } /** A request that stays open until the test answers it. */ function openRequest() { let answer: (saved: { id: string }) => void = () => undefined; const promise = new Promise<{ id: string }>((resolve) => { answer = resolve; }); return { promise: promise as never, answer }; } /** Lets every already-runnable promise callback run. */ function settle(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } function sentColonies(): string[] { return createInspection.mock.calls.map(([input]) => input.colonyId); } function takePhoto(sessionId: string) { return photos.addDraftPhoto({ sessionId, ownerUserId: "user-1", blob: new Blob(["jpeg"], { type: "image/jpeg" }), date: "2026-10-01", }); } describe("syncPendingEntries", () => { beforeEach(async () => { // A fresh database and fresh modules: nothing (no open connection, no // in-flight run) carries over from the previous test. globalThis.indexedDB = new IDBFactory(); window.localStorage.clear(); vi.resetModules(); db = await import("./db"); sync = await import("./sync"); photos = await import("@/lib/photos/store"); ApiError = (await import("@/lib/auth/api-client")).ApiError; createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection); createTreatment = vi.mocked((await import("@/lib/treatments/api")).createTreatment); uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument); createPhenologyReport = vi.mocked( (await import("@/lib/phenology/api")).createPhenologyReport, ); refreshPacksAfterSync = vi.mocked( (await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync, ); // The mocks themselves outlive resetModules - start them from scratch too. createInspection.mockReset(); createTreatment.mockReset(); uploadDocument.mockReset(); createPhenologyReport.mockReset(); refreshPacksAfterSync.mockReset().mockResolvedValue(undefined); photoHandOver.calls.length = 0; photoHandOver.failFor = null; photoHandOver.isQueued = async (key) => (await db.getPendingEntries("pending-inspections")).some( (entry) => entry.idempotencyKey === key, ); }); it("syncs every queued entry and removes it from the queue", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 }); expect(await db.getPendingEntries("pending-inspections")).toEqual([]); }); it("uploads recorded voice notes after the records, inside the run", async () => { const order: string[] = []; const { uploadVoiceCapture } = await import("@/lib/voice/api"); vi.mocked(uploadVoiceCapture).mockReset().mockImplementation(async () => { order.push("voice"); return { id: "cap-1", status: "QUEUED" as const }; }); createInspection.mockImplementation(async () => { order.push("inspection"); return { id: "x" } as never; }); await db.queueInspection(inspection(1), "key-1", "user-1"); const raw = await openDB("apicore-offline"); await raw.put("pending-voice", { id: "v1", ownerUserId: "user-1", blob: new Blob(["audio"]), mime: "audio/webm", durationSec: 20, colonyId: "colony-1", inspectionDate: "2026-10-16", createdAt: "2026-10-16T08:00:00.000Z", uploadIdempotencyKey: "upload-v1", state: "recorded", }); raw.close(); const result = await sync.syncPendingEntries("user-1"); expect(order).toEqual(["inspection", "voice"]); expect(result).toEqual({ synced: 2, failed: 0, skippedForeign: 0 }); }); it("a cancelled run sends no voice note", async () => { const { uploadVoiceCapture } = await import("@/lib/voice/api"); vi.mocked(uploadVoiceCapture).mockReset(); await db.getPendingEntries("pending-inspections"); const raw = await openDB("apicore-offline"); await raw.put("pending-voice", { id: "v1", ownerUserId: "user-1", blob: new Blob(["audio"]), mime: "audio/webm", durationSec: 20, colonyId: "colony-1", inspectionDate: "2026-10-16", createdAt: "2026-10-16T08:00:00.000Z", uploadIdempotencyKey: "upload-v1", state: "recorded", }); raw.close(); const run = sync.syncPendingEntries("user-1"); await sync.cancelSync(); await run; sync.resumeSync(); expect(uploadVoiceCapture).not.toHaveBeenCalled(); }); it("offline the plant report is queued and synced later with its key", async () => { const report = { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null, } as const; await db.queuePhenologyReport(report, "plant-key-0000000001", "user-1"); createPhenologyReport.mockResolvedValue({ id: "r-1" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(createPhenologyReport).toHaveBeenCalledWith(report, { idempotencyKey: "plant-key-0000000001", }); expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([]); }); it("skips a plant report of another account", async () => { await db.queuePhenologyReport( { phase: "SALWEIDE", date: "2026-03-20" }, "plant-key-0000000004", "user-2", ); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 }); expect(createPhenologyReport).not.toHaveBeenCalled(); expect(await db.getPendingEntries("pending-phenology-reports")).toHaveLength(1); }); it("keeps a plant report the server rejected, with the reason", async () => { await db.queuePhenologyReport( { phase: "KIRSCHE", date: "2026-04-02" }, "plant-key-0000000002", "user-1", ); createPhenologyReport.mockRejectedValue( new ApiError(400, "Das Datum liegt in der Zukunft.", {}), ); const result = await sync.syncPendingEntries("user-1"); expect(result.failed).toBe(1); const [entry] = await db.getPendingEntries("pending-phenology-reports"); expect(entry.lastError?.message).toBe("Das Datum liegt in der Zukunft."); }); it("resends a queued entry with the idempotency key it was queued with", async () => { // The server may already have saved the first attempt; the same key lets // it answer with that record instead of storing a duplicate. await db.queueInspection(inspection(1), "key-from-the-form-0001", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); await sync.syncPendingEntries("user-1"); expect(createInspection).toHaveBeenCalledWith(inspection(1), { idempotencyKey: "key-from-the-form-0001", }); }); it("sends an inspection queued by the previous version with its legacy feed word unchanged", async () => { // The server maps ausreichend/niedrig/kritisch itself; the queue must not rewrite or reject them. const legacy = { ...inspection(1), feedStatus: "niedrig" } as never; await db.queueInspection(legacy, "key-legacy-feed-0001", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(createInspection).toHaveBeenCalledWith( expect.objectContaining({ feedStatus: "niedrig" }), { idempotencyKey: "key-legacy-feed-0001" }, ); }); it("sends the Volkskarte fields of a queued inspection along", async () => { const queued = { ...inspection(1), feedStatus: "WEAK", applyToColony: ["feedStatus"] } as never; await db.queueInspection(queued, "key-takeover-0001", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); await sync.syncPendingEntries("user-1"); expect(createInspection).toHaveBeenCalledWith( expect.objectContaining({ feedStatus: "WEAK", applyToColony: ["feedStatus"] }), { idempotencyKey: "key-takeover-0001" }, ); }); it("still sends entries queued before keys and owners existed", async () => { await db.summarizeQueue("user-1"); // creates the database at the current version const raw = await openDB("apicore-offline"); await raw.add("pending-inspections", { input: inspection(1), queuedAt: "2026-10-01T08:00:00.000Z", }); raw.close(); createInspection.mockResolvedValue({ id: "x" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(createInspection).toHaveBeenCalledWith(inspection(1), { idempotencyKey: undefined }); }); it("stores the server rejection on the entry and keeps it", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); const body = { statusCode: 400, message: "Volk V-03 ist beendet." }; createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.", body)); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 }); const [kept] = await db.getPendingEntries("pending-inspections"); expect(kept.idempotencyKey).toBe("key-1"); expect(kept.lastError).toEqual({ status: 400, message: "Volk V-03 ist beendet.", details: body, at: expect.any(String), }); expect(kept.lastError?.status).toBe(400); }); it("an inspection for a colony ended since the pack was loaded stays queued with the server message and the packs are refreshed", async () => { // Review Focus 1: the pack still listed V-03 as living when the // inspection was recorded offline; the server knows better. await db.queueInspection(inspection(3), "key-3", "user-1"); const body = { statusCode: 400, message: "Volk V-03 ist beendet." }; createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.", body)); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 }); const [kept] = await db.getPendingEntries("pending-inspections"); expect(kept.lastError?.message).toBe("Volk V-03 ist beendet."); expect(kept.lastError?.status).toBe(400); // The packs holding V-03 are reloaded at once, not only after 1 h. expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1); expect(refreshPacksAfterSync).toHaveBeenCalledWith( "user-1", expect.any(Number), expect.any(Function), { force: true, colonyIds: ["colony-3"] }, ); }); it("refreshes the packs by age only when nothing was rejected", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); await sync.syncPendingEntries("user-1"); expect(refreshPacksAfterSync).toHaveBeenCalledTimes(1); expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3); }); it("does not force a pack reload after a network failure", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); createInspection.mockRejectedValue(new TypeError("Failed to fetch")); await sync.syncPendingEntries("user-1"); expect(refreshPacksAfterSync.mock.calls[0]).toHaveLength(3); }); it("forces the reload for the colonies of every rejected entry, each once", async () => { await db.queueInspection(inspection(3), "key-3", "user-1"); await db.queueInspection(inspection(3), "key-3b", "user-1"); await db.queueTreatment( { colonyId: "colony-5", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" }, "key-t", "user-1", ); createInspection.mockRejectedValue(new ApiError(400, "Volk V-03 ist beendet.")); createTreatment.mockRejectedValue(new ApiError(400, "Volk V-05 ist beendet.")); await sync.syncPendingEntries("user-1"); const options = refreshPacksAfterSync.mock.calls[0][3]; expect(options?.force).toBe(true); expect([...(options?.colonyIds ?? [])].sort()).toEqual(["colony-3", "colony-5"]); }); it("refreshes the packs only for the run's user and never after cancelSync()", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); const request = openRequest(); createInspection.mockReturnValueOnce(request.promise); const run = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); const cancelled = sync.cancelSync(); request.answer({ id: "server-1" }); await run; await cancelled; expect(refreshPacksAfterSync).not.toHaveBeenCalled(); }); it("continues with the rest of the queue after a server rejection", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); createInspection .mockRejectedValueOnce(new ApiError(400, "invalid colony")) .mockResolvedValueOnce({ id: "x" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 1, failed: 1, skippedForeign: 0 }); const remaining = await db.getPendingEntries("pending-inspections"); expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-1"]); }); it("stops immediately on a network failure, leaving the rest queued untouched", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); createInspection.mockRejectedValueOnce(new TypeError("Failed to fetch")); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 0, failed: 1, skippedForeign: 0 }); expect(createInspection).toHaveBeenCalledTimes(1); const remaining = await db.getPendingEntries("pending-inspections"); expect(remaining).toHaveLength(2); expect(remaining.every((entry) => entry.lastError === undefined)).toBe(true); }); it("never sends entries queued by another user", async () => { await db.queueInspection(inspection(1), "key-1", "user-2"); createInspection.mockResolvedValue({ id: "x" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 0, failed: 0, skippedForeign: 1 }); expect(createInspection).not.toHaveBeenCalled(); expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1); }); it("shares a single in-flight run across concurrent callers instead of double-processing the queue", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); createInspection.mockResolvedValue({ id: "x" } as never); const [first, second] = await Promise.all([ sync.syncPendingEntries("user-1"), sync.syncPendingEntries("user-1"), ]); expect(first).toBe(second); expect(createInspection).toHaveBeenCalledTimes(1); }); it("a caller that changed nothing shares the running run, even once it has begun", async () => { await takePhoto("session-1"); await photos.attachSessionToInspection("session-1", "srv-1"); const upload = openRequest(); uploadDocument.mockReturnValueOnce(upload.promise); const running = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1)); // The band's triggers on a reconnect (`online` event, connectivity check). const reconnect = sync.syncPendingEntries("user-1"); upload.answer({ id: "doc-1" }); expect(reconnect).toBe(running); expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(uploadDocument).toHaveBeenCalledTimes(1); }); it("runs exactly once more for callers that changed something after the run began, so it does not wait", async () => { await takePhoto("session-1"); await photos.attachSessionToInspection("session-1", "srv-1"); const firstUpload = openRequest(); uploadDocument .mockReturnValueOnce(firstUpload.promise) .mockResolvedValue({ id: "doc-2" } as never); const running = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(uploadDocument).toHaveBeenCalledTimes(1)); // An online save while the running sync uploads: its photo is ready only now. await takePhoto("session-2"); await photos.attachSessionToInspection("session-2", "srv-2"); const afterSave = sync.syncPendingEntries("user-1", { afterChange: true }); // e.g. „Erneut senden“ const afterRetry = sync.syncPendingEntries("user-1", { afterChange: true }); firstUpload.answer({ id: "doc-1" }); expect(await running).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(afterRetry).toBe(afterSave); expect(await afterSave).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(uploadDocument.mock.calls.map(([, inspectionId]) => inspectionId)).toEqual([ "srv-1", "srv-2", ]); expect(await photos.listSessionPhotos("session-2")).toEqual([]); }); it("never sends a user's entries after cancelSync() during a running sync", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); const firstRequest = openRequest(); createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never); const run = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); // Logout: stop the run before the access token changes. const cancelled = sync.cancelSync(); firstRequest.answer({ id: "saved-1" }); await cancelled; // The request already on its way is finished properly, nothing after it is sent. expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(sentColonies()).toEqual(["colony-1"]); const remaining = await db.getPendingEntries("pending-inspections"); expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]); }); it("starts another user's run only after the current one finished", async () => { await db.queueInspection(inspection(1), "key-a1", "user-a"); await db.queueInspection(inspection(2), "key-b1", "user-b"); const firstRequest = openRequest(); createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never); const runA = sync.syncPendingEntries("user-a"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); const runB = sync.syncPendingEntries("user-b"); await settle(); expect(createInspection).toHaveBeenCalledTimes(1); firstRequest.answer({ id: "saved-a1" }); expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 }); expect(await runB).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(sentColonies()).toEqual(["colony-1", "colony-2"]); }); it("after a logout and the next login, never sends the first user's entries", async () => { await db.queueInspection(inspection(1), "key-a1", "user-a"); await db.queueInspection(inspection(2), "key-a2", "user-a"); await db.queueInspection(inspection(3), "key-b1", "user-b"); const firstRequest = openRequest(); createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never); const runA = sync.syncPendingEntries("user-a"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); // A signs out (the cancel is awaited before the token changes) ... const cancelled = sync.cancelSync(); firstRequest.answer({ id: "saved-a1" }); await cancelled; // ... and B signs in on the same device: B's token is set, sync opens again. sync.resumeSync(); const runB = await sync.syncPendingEntries("user-b"); expect(await runA).toEqual({ synced: 1, failed: 0, skippedForeign: 1 }); expect(runB).toEqual({ synced: 1, failed: 0, skippedForeign: 1 }); expect(sentColonies()).toEqual(["colony-1", "colony-3"]); const remaining = await db.getPendingEntries("pending-inspections"); expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-a2"]); }); it("sends nothing between cancelSync() and resumeSync(), not even for the same user", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); const firstRequest = openRequest(); createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never); void sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); const cancelled = sync.cancelSync(); // E.g. an `online` event while the logout still waits for the cancel. const late = sync.syncPendingEntries("user-1"); firstRequest.answer({ id: "saved-1" }); await cancelled; expect(await late).toEqual({ synced: 0, failed: 0, skippedForeign: 0 }); expect(await sync.syncPendingEntries("user-1")).toEqual({ synced: 0, failed: 0, skippedForeign: 0, }); expect(sentColonies()).toEqual(["colony-1"]); sync.resumeSync(); expect(await sync.syncPendingEntries("user-1")).toEqual({ synced: 1, failed: 0, skippedForeign: 0, }); expect(sentColonies()).toEqual(["colony-1", "colony-2"]); }); it("re-checks each entry right before sending it", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); await db.queueInspection(inspection(3), "key-3", "user-1"); const firstRequest = openRequest(); createInspection.mockReturnValueOnce(firstRequest.promise).mockResolvedValue({ id: "x" } as never); const run = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); // While the first request is open: the second entry is discarded in the // queue sheet, and the third no longer belongs to this user. const [, second, third] = await db.getPendingEntries("pending-inspections"); await db.discardQueueItem({ store: "pending-inspections", id: second.id, label: "Durchsicht", queuedAt: second.queuedAt, }); const raw = await openDB("apicore-offline"); await raw.put("pending-inspections", { ...third, ownerUserId: "user-2" }); raw.close(); firstRequest.answer({ id: "saved-1" }); expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 1 }); expect(sentColonies()).toEqual(["colony-1"]); }); it("uploads photos only after their inspection synced in the same run", async () => { await db.queueInspection(inspection(1), "inspection-key", "user-1"); await db.queueTreatment( { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" }, "treatment-key", "user-1", ); const photo = await takePhoto("session-1"); await photos.attachSessionToQueuedInspection("session-1", "inspection-key"); createInspection.mockResolvedValue({ id: "srv-1" } as never); const treatmentRequest = openRequest(); createTreatment.mockReturnValueOnce(treatmentRequest.promise); uploadDocument.mockResolvedValue({ id: "doc-1" } as never); const run = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createTreatment).toHaveBeenCalledTimes(1)); await vi.waitFor(async () => expect(await db.getPendingEntries("pending-inspections")).toEqual([]), ); // The inspection is on the server, another record drain still runs: no photo yet. await settle(); expect(uploadDocument).not.toHaveBeenCalled(); treatmentRequest.answer({ id: "srv-t1" }); expect(await run).toEqual({ synced: 3, failed: 0, skippedForeign: 0 }); // The photos got the server id while the inspection was still queued. expect(photoHandOver.calls).toEqual([ { key: "inspection-key", inspectionId: "srv-1", entryStillQueued: true }, ]); expect(uploadDocument).toHaveBeenCalledTimes(1); expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), { idempotencyKey: photo.uploadIdempotencyKey, fileName: photo.fileName, }); expect(await photos.listSessionPhotos("session-1")).toEqual([]); }); it("finishes the user's open photo hand-overs before uploading, never another user's", async () => { const own = await takePhoto("session-1"); await photos.addDraftPhoto({ sessionId: "session-2", ownerUserId: "user-2", blob: new Blob(["jpeg"], { type: "image/jpeg" }), date: "2026-10-01", }); // Saved online, but the photos could not be tied to the inspection then. rememberHandOver({ userId: "user-1", sessionId: "session-1", inspectionId: "srv-1" }); rememberHandOver({ userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" }); uploadDocument.mockResolvedValue({ id: "doc-1" } as never); const result = await sync.syncPendingEntries("user-1"); expect(result).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(uploadDocument).toHaveBeenCalledTimes(1); expect(uploadDocument).toHaveBeenCalledWith("INSPECTION", "srv-1", expect.anything(), { idempotencyKey: own.uploadIdempotencyKey, fileName: own.fileName, }); expect(readHandOvers()).toEqual([ { userId: "user-2", sessionId: "session-2", inspectionId: "srv-2" }, ]); const [foreign] = await photos.listSessionPhotos("session-2"); expect(foreign.state).toBe("draft"); }); it("keeps an inspection whose photo hand-over failed and still finishes the run", async () => { await db.queueInspection(inspection(1), "key-1", "user-1"); await db.queueInspection(inspection(2), "key-2", "user-1"); const photo = await takePhoto("session-1"); await photos.attachSessionToQueuedInspection("session-1", "key-1"); createInspection .mockResolvedValueOnce({ id: "srv-1" } as never) .mockResolvedValueOnce({ id: "srv-2" } as never); photoHandOver.failFor = "key-1"; const first = await sync.syncPendingEntries("user-1"); expect(first).toEqual({ synced: 1, failed: 1, skippedForeign: 0 }); const [kept] = await db.getPendingEntries("pending-inspections"); expect(kept.idempotencyKey).toBe("key-1"); expect(kept.lastError).toBeUndefined(); expect(uploadDocument).not.toHaveBeenCalled(); // Next run: the same key brings back the same server record, the photo follows it. photoHandOver.failFor = null; createInspection.mockResolvedValue({ id: "srv-1" } as never); uploadDocument.mockResolvedValue({ id: "doc-1" } as never); const second = await sync.syncPendingEntries("user-1"); expect(second).toEqual({ synced: 2, failed: 0, skippedForeign: 0 }); expect(createInspection).toHaveBeenLastCalledWith(inspection(1), { idempotencyKey: "key-1" }); expect(uploadDocument).toHaveBeenCalledWith( "INSPECTION", "srv-1", expect.anything(), expect.objectContaining({ idempotencyKey: photo.uploadIdempotencyKey }), ); expect(await db.getPendingEntries("pending-inspections")).toEqual([]); }); it("never uploads photos after cancelSync()", async () => { await db.queueInspection(inspection(1), "inspection-key", "user-1"); await takePhoto("session-1"); await photos.attachSessionToQueuedInspection("session-1", "inspection-key"); const request = openRequest(); createInspection.mockReturnValueOnce(request.promise); uploadDocument.mockResolvedValue({ id: "doc-1" } as never); const run = sync.syncPendingEntries("user-1"); await vi.waitFor(() => expect(createInspection).toHaveBeenCalledTimes(1)); const cancelled = sync.cancelSync(); request.answer({ id: "srv-1" }); await cancelled; expect(await run).toEqual({ synced: 1, failed: 0, skippedForeign: 0 }); expect(uploadDocument).not.toHaveBeenCalled(); const [photo] = await photos.listSessionPhotos("session-1"); expect(photo).toMatchObject({ state: "ready", inspectionId: "srv-1" }); }); }); describe("drainHiveTimeline", () => { let postHiveChange: MockedFunction; const op = (value: string) => ({ op: "setLabel" as const, id: "b", label: value }); const hiveEntry = (colonyId: string, value: string) => ({ colonyId, occurredOn: "2026-10-15", source: "CARD" as const, ops: [op(value)], }); const withHive = (colony: number, value: string) => ({ ...inspection(colony), hiveChange: { ops: [op(value)] }, }); /** Entries queued in the same millisecond would tie on queuedAt. */ const tick = () => new Promise((resolve) => setTimeout(resolve, 3)); const labelsSent = () => postHiveChange.mock.calls.map(([, input]) => (input.ops[0] as { label: string }).label); beforeEach(async () => { globalThis.indexedDB = new IDBFactory(); window.localStorage.clear(); vi.resetModules(); db = await import("./db"); sync = await import("./sync"); ApiError = (await import("@/lib/auth/api-client")).ApiError; createInspection = vi.mocked((await import("@/lib/inspections/api")).createInspection); postHiveChange = vi.mocked((await import("@/lib/hive/api")).postHiveChange); // The describe above leaves a mock that resolves uploads: start from scratch. uploadDocument = vi.mocked((await import("@/lib/documents/api")).uploadDocument); uploadDocument.mockReset(); // The photo store mock wraps the first module instance, which keeps its // own connection: photos of earlier tests are still there and would be uploaded. const photoStore = await import("@/lib/photos/store"); for (const photo of await photoStore.listPendingPhotos("user-1")) { await photoStore.discardReadyPhoto(photo.id); } refreshPacksAfterSync = vi.mocked( (await import("@/lib/apiaries/offline-pack")).refreshPacksAfterSync, ); createInspection.mockReset().mockResolvedValue({ id: "i" } as never); postHiveChange.mockReset().mockResolvedValue({} as never); refreshPacksAfterSync.mockReset().mockResolvedValue(undefined); }); it("sends hive entries and inspections with a hive change in queuedAt order across both queues", async () => { const order: string[] = []; postHiveChange.mockImplementation(async (_c, input) => { order.push(`hive:${(input.ops[0] as { label: string }).label}`); return {} as never; }); createInspection.mockImplementation(async (input) => { order.push(`inspection:${(input.hiveChange?.ops[0] as { label: string }).label}`); return { id: "i" } as never; }); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await tick(); await db.queueInspection(withHive(1, "b") as never, "k2", "user-1"); await tick(); await db.queueHiveChange(hiveEntry("colony-1", "c"), "k3", "user-1"); const result = await sync.syncPendingEntries("user-1"); expect(order).toEqual(["hive:a", "inspection:b", "hive:c"]); expect(result).toEqual({ synced: 3, failed: 0, skippedForeign: 0 }); expect(await db.getPendingEntries("pending-hive-changes")).toEqual([]); expect(await db.getPendingEntries("pending-inspections")).toEqual([]); }); it("sends each entry with its own idempotency key", async () => { await db.queueHiveChange(hiveEntry("colony-1", "a"), "key-hive", "user-1"); await sync.syncPendingEntries("user-1"); expect(postHiveChange).toHaveBeenCalledWith( "colony-1", { occurredOn: "2026-10-15", source: "CARD", ops: [op("a")] }, "key-hive", ); }); it("leaves inspections with a hive change to the timeline and sends plain ones in the normal drain", async () => { await db.queueInspection(inspection(1), "plain", "user-1"); await db.queueInspection(withHive(2, "x") as never, "with-hive", "user-1"); await sync.syncPendingEntries("user-1"); expect(createInspection).toHaveBeenCalledTimes(2); expect(createInspection.mock.calls.map(([, options]) => options?.idempotencyKey).sort()).toEqual([ "plain", "with-hive", ]); }); it("holds later hive entries of the colony after a conflict, but not other colonies", async () => { postHiveChange.mockImplementation(async (colonyId, input) => { if (colonyId === "colony-1" && (input.ops[0] as { label: string }).label === "a") { throw new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: x" }); } return {} as never; }); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await tick(); await db.queueInspection(withHive(1, "b") as never, "k2", "user-1"); await tick(); await db.queueHiveChange(hiveEntry("colony-2", "c"), "k3", "user-1"); const result = await sync.syncPendingEntries("user-1"); expect(labelsSent()).toEqual(["a", "c"]); expect(createInspection).not.toHaveBeenCalled(); expect(result.failed).toBe(1); const [held] = await db.getPendingEntries("pending-inspections"); expect(held.lastError).toBeUndefined(); const [rejected] = await db.getPendingEntries("pending-hive-changes"); expect(rejected.lastError).toMatchObject({ status: 409 }); }); it("keeps only the small part of a conflict body in the stored rejection", async () => { postHiveChange.mockRejectedValue( new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 2, reason: "MISSING_ID: x", current: { version: 7, state: { schemaVersion: 1, stack: [{ id: "big" }] } }, }), ); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await sync.syncPendingEntries("user-1"); const [entry] = await db.getPendingEntries("pending-hive-changes"); expect(entry.lastError?.details).toEqual({ code: "HIVE_CONFLICT", opIndex: 2, reason: "MISSING_ID: x", current: { version: 7 }, }); }); it("holds the colony after a 403 and marks the inspection with the hive change", async () => { createInspection.mockRejectedValue(new ApiError(403, "Forbidden")); await db.queueInspection(withHive(1, "a") as never, "k1", "user-1"); await tick(); await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1"); await sync.syncPendingEntries("user-1"); expect(postHiveChange).not.toHaveBeenCalled(); const [entry] = await db.getPendingEntries("pending-inspections"); expect(entry.lastError).toMatchObject({ status: 403 }); }); it("stops at a network error and sends the rest in the next run, in order", async () => { postHiveChange.mockRejectedValueOnce(new TypeError("Failed to fetch")); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await tick(); await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1"); await sync.syncPendingEntries("user-1"); expect(labelsSent()).toEqual(["a"]); expect((await db.getPendingEntries("pending-hive-changes")).map((e) => e.lastError)).toEqual([ undefined, undefined, ]); await sync.syncPendingEntries("user-1"); expect(labelsSent()).toEqual(["a", "a", "b"]); }); it("stops at a 5xx instead of marking the entry", async () => { postHiveChange.mockRejectedValueOnce(new ApiError(500, "Boom")); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await db.queueHiveChange(hiveEntry("colony-2", "b"), "k2", "user-1"); await sync.syncPendingEntries("user-1"); expect(labelsSent()).toEqual(["a"]); const entries = await db.getPendingEntries("pending-hive-changes"); expect(entries.every((entry) => entry.lastError === undefined)).toBe(true); }); it("treats a key still in progress (409) as retry later and holds the colony", async () => { postHiveChange.mockRejectedValueOnce( new ApiError(409, "Diese Anfrage wird bereits verarbeitet - bitte kurz warten"), ); await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-1"); await db.queueHiveChange(hiveEntry("colony-1", "b"), "k2", "user-1"); await sync.syncPendingEntries("user-1"); expect(labelsSent()).toEqual(["a"]); expect((await db.getPendingEntries("pending-hive-changes"))[0].lastError).toBeUndefined(); }); it("never sends another account's hive entries", async () => { await db.queueHiveChange(hiveEntry("colony-1", "a"), "k1", "user-2"); const result = await sync.syncPendingEntries("user-1"); expect(postHiveChange).not.toHaveBeenCalled(); expect(result.skippedForeign).toBe(1); }); it("sends the inspection without its hive change after strip-and-resend, with the same key", async () => { createInspection.mockRejectedValueOnce(new ApiError(403, "Forbidden")); await db.queueInspection(withHive(1, "a") as never, "same-key", "user-1"); await sync.syncPendingEntries("user-1"); const [rejected] = await db.getPendingEntries("pending-inspections"); await db.retryInspectionWithoutHiveChange(rejected.id); await sync.syncPendingEntries("user-1"); const [input, options] = createInspection.mock.calls[1]; expect(input).not.toHaveProperty("hiveChange"); expect(options?.idempotencyKey).toBe("same-key"); expect(await db.getPendingEntries("pending-inspections")).toEqual([]); }); });