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/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 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); 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(); 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("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("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" }); }); });