import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; import { openDB } from "idb"; import { beforeEach, describe, expect, it, vi } from "vitest"; type OfflineDbModule = typeof import("./db"); const DB_NAME = "apicore-offline"; const VERSION_2_STORES = [ "pending-inspections", "pending-treatments", "pending-feedings", "pending-varroa-measurements", "pending-swarm-control-checks", "pending-harvests", ]; const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" }; const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" }; const FEEDING = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", feedType: "FEED_SYRUP" as const, amount: 2, unit: "kg", }; const REJECTION = { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z", }; function photo(id: string, inspectionIdempotencyKey: string, ownerUserId = "user-1") { return { id, sessionId: "session-1", ownerUserId, blob: new Blob(["jpeg"]), fileName: `${id}.jpg`, createdAt: "2026-10-13T08:00:00.000Z", state: "waiting-for-inspection", inspectionIdempotencyKey, uploadIdempotencyKey: `upload-${id}`, }; } /** A fresh module, so nothing is cached from the previous test's database. */ async function loadDb(): Promise { vi.resetModules(); return import("./db"); } /** The queue exactly as the previous release (DB version 2) left it on a phone. */ async function createVersion2Queue(entry: object): Promise { const db = await openDB(DB_NAME, 2, { upgrade(database) { for (const store of VERSION_2_STORES) { database.createObjectStore(store, { keyPath: "id", autoIncrement: true }); } }, }); await db.add("pending-inspections", entry); db.close(); } /** A device that last opened the app with DB v3 (before plant reports). */ async function createVersion3Queue(inspection: object): Promise { const db = await openDB(DB_NAME, 3, { upgrade(database) { for (const store of [...VERSION_2_STORES, "pending-bulk"]) { database.createObjectStore(store, { keyPath: "id", autoIncrement: true, }); } const photos = database.createObjectStore("pending-photos", { keyPath: "id", }); photos.createIndex("sessionId", "sessionId"); photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey"); photos.createIndex("ownerUserId", "ownerUserId"); database.createObjectStore("offline-packs", { keyPath: "apiaryId" }); database.createObjectStore("meta"); }, }); await db.add("pending-inspections", inspection); db.close(); } describe("offline queue database", () => { beforeEach(() => { globalThis.indexedDB = new IDBFactory(); }); it.each([ ["a fresh install (0)", 0], ["version 1", 1], ])("opening from %s creates the plant report store at version 6", async (_name, from) => { if (from === 1) { const old = await openDB(DB_NAME, 1, { upgrade(database) { database.createObjectStore("pending-inspections", { keyPath: "id", autoIncrement: true, }); }, }); await old.add("pending-inspections", { input: INSPECTION, queuedAt: "2026-10-01T08:00:00.000Z", }); old.close(); } const db = await loadDb(); await db.queuePhenologyReport( { phase: "APFEL", date: "2026-04-20" }, "plant-key-0000000003", "user-1", ); const upgraded = await openDB(DB_NAME); expect(upgraded.version).toBe(6); expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports"); expect(await upgraded.count("pending-phenology-reports")).toBe(1); expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0); upgraded.close(); }); it("db upgrade 3 -> 4 keeps queued inspections and adds the plant report queue", async () => { await createVersion3Queue({ input: INSPECTION, idempotencyKey: "key-from-v3", ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z", }); const db = await loadDb(); const entries = await db.getPendingEntries("pending-inspections"); expect(entries).toEqual([ { id: 1, input: INSPECTION, idempotencyKey: "key-from-v3", ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z", }, ]); await db.queuePhenologyReport( { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null }, "key-plant-0000000001", "user-1", ); expect(await db.getPendingEntries("pending-phenology-reports")).toEqual([ expect.objectContaining({ input: { phase: "SALWEIDE", date: "2026-03-20", apiaryId: null }, idempotencyKey: "key-plant-0000000001", ownerUserId: "user-1", }), ]); expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 0, foreign: 0, }); expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung"); const upgraded = await openDB(DB_NAME); expect(upgraded.version).toBe(6); upgraded.close(); }); it("upgrading v2 → v6 keeps already queued inspections", async () => { await createVersion2Queue({ input: INSPECTION, idempotencyKey: "key-from-v2", queuedAt: "2026-10-01T08:00:00.000Z", }); const db = await loadDb(); const entries = await db.getPendingEntries("pending-inspections"); expect(entries).toEqual([ { id: 1, input: INSPECTION, idempotencyKey: "key-from-v2", queuedAt: "2026-10-01T08:00:00.000Z", }, ]); // Entries from before owners existed still count as the user's own. expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 }); const upgraded = await openDB(DB_NAME); expect(upgraded.version).toBe(6); expect([...upgraded.objectStoreNames]).toEqual( expect.arrayContaining([ ...VERSION_2_STORES, "pending-bulk", "pending-photos", "offline-packs", "meta", "pending-phenology-reports", "pending-hive-changes", ]), ); expect([...upgraded.transaction("pending-photos").store.indexNames].sort()).toEqual([ "inspectionIdempotencyKey", "ownerUserId", "sessionId", ]); upgraded.close(); }); it("db upgrade 4 -> 5 keeps every store and entry and adds the hive change queue", async () => { const old = await openDB(DB_NAME, 4, { upgrade(database) { for (const store of [...VERSION_2_STORES, "pending-bulk", "pending-phenology-reports"]) { database.createObjectStore(store, { keyPath: "id", autoIncrement: true }); } database.createObjectStore("pending-photos", { keyPath: "id" }); database.createObjectStore("offline-packs", { keyPath: "apiaryId" }); database.createObjectStore("meta"); }, }); await old.add("pending-inspections", { input: INSPECTION, queuedAt: "2026-10-01T08:00:00.000Z" }); await old.add("pending-phenology-reports", { input: {}, queuedAt: "2026-10-01T09:00:00.000Z" }); old.close(); const db = await loadDb(); await db.queueHiveChange( { colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] }, "key-h", "user-1", ); const [hive] = await db.getPendingEntries("pending-hive-changes"); expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" }); const upgraded = await openDB(DB_NAME); expect(upgraded.version).toBe(6); expect(await upgraded.count("pending-inspections")).toBe(1); expect(await upgraded.count("pending-phenology-reports")).toBe(1); expect([...upgraded.objectStoreNames]).toEqual( expect.arrayContaining(["pending-bulk", "pending-photos", "offline-packs", "meta", "pending-hive-changes"]), ); upgraded.close(); }); it("upgrading to the new version keeps queued inspections, photos and phenology reports", async () => { const old = await openDB(DB_NAME, 5, { upgrade(database) { for (const store of [ ...VERSION_2_STORES, "pending-bulk", "pending-phenology-reports", "pending-hive-changes", ]) { database.createObjectStore(store, { keyPath: "id", autoIncrement: true }); } const photos = database.createObjectStore("pending-photos", { keyPath: "id" }); photos.createIndex("sessionId", "sessionId"); photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey"); photos.createIndex("ownerUserId", "ownerUserId"); database.createObjectStore("offline-packs", { keyPath: "apiaryId" }); database.createObjectStore("meta"); }, }); await old.add("pending-inspections", { input: INSPECTION, ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z" }); await old.add("pending-phenology-reports", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T09:00:00.000Z" }); await old.add("pending-hive-changes", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T10:00:00.000Z" }); await old.put("pending-photos", photo("p1", "key-1")); old.close(); const db = await loadDb(); expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1); const upgraded = await openDB(DB_NAME); expect(upgraded.version).toBe(6); expect(await upgraded.count("pending-phenology-reports")).toBe(1); expect(await upgraded.count("pending-hive-changes")).toBe(1); expect(await upgraded.count("pending-photos")).toBe(1); expect([...upgraded.objectStoreNames]).toContain("pending-voice"); expect([...upgraded.transaction("pending-voice").store.indexNames]).toEqual(["ownerUserId"]); upgraded.close(); }); it("clearQueueOf removes one owner's voice notes and keeps everyone else's", async () => { const db = await loadDb(); await db.getDb(); const raw = await openDB(DB_NAME); const note = (id: string, ownerUserId: string) => ({ id, ownerUserId, blob: new Blob(["x"]), mime: "audio/webm;codecs=opus", durationSec: 5, colonyId: "c-1", inspectionDate: "2026-10-16", createdAt: "2026-10-16T08:00:00.000Z", uploadIdempotencyKey: `u-${id}`, state: "recorded", }); await raw.put("pending-voice", note("v1", "user-1") as never); await raw.put("pending-voice", note("v2", "user-2") as never); await db.clearQueueOf("user-1"); expect(await raw.getAllKeys("pending-voice")).toEqual(["v2"]); raw.close(); }); it("lists a hive change under its own name and marks inspections that carry one", async () => { const db = await loadDb(); await db.queueHiveChange( { colonyId: "colony-1", occurredOn: "2026-10-15", source: "CARD", ops: [] }, "key-h", "user-1", ); await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1"); await db.queueInspection(INSPECTION, "key-j", "user-1"); const items = await db.listQueue("user-1"); expect(items.map((item) => [item.label, item.hasHiveChange ?? false]).sort()).toEqual([ ["Beute-Änderung", true], ["Durchsicht", false], ["Durchsicht", true], ]); }); it("retryInspectionWithoutHiveChange strips the hive change, clears the rejection and keeps the key", async () => { const db = await loadDb(); await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "key-i", "user-1"); const [entry] = await db.getPendingEntries("pending-inspections"); await db.markFailed("pending-inspections", entry.id, { ...REJECTION, status: 403 }); expect((await db.listQueue("user-1"))[0].canResendWithoutHiveChange).toBe(true); await db.retryInspectionWithoutHiveChange(entry.id); const [after] = await db.getPendingEntries("pending-inspections"); expect(after.input).toEqual(INSPECTION); expect(after.lastError).toBeUndefined(); expect(after.idempotencyKey).toBe("key-i"); }); it("offers „ohne Beute-Änderung“ only for a 403 or a hive conflict, not for any other rejection", async () => { const db = await loadDb(); await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k1", "user-1"); await db.queueInspection({ ...INSPECTION, hiveChange: { ops: [] } }, "k2", "user-1"); const [plain, conflict] = await db.getPendingEntries("pending-inspections"); await db.markFailed("pending-inspections", plain.id, REJECTION); await db.markFailed("pending-inspections", conflict.id, { status: 409, message: "Konflikt", details: { code: "HIVE_CONFLICT" }, at: REJECTION.at, }); const items = await db.listQueue("user-1"); expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]); }); it("summarizeQueue separates own, failed and foreign entries", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "key-1", "user-1"); await db.queueTreatment(TREATMENT, "key-2", "user-1"); await db.queueFeeding(FEEDING, "key-3", "user-2"); const [treatment] = await db.getPendingEntries("pending-treatments"); await db.markFailed("pending-treatments", treatment.id, REJECTION); expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 1, foreign: 1 }); expect(await db.summarizeQueue("user-2")).toEqual({ own: 1, failed: 0, foreign: 2 }); }); it("summarizeQueue counts a photo waiting on its own like an entry, never a draft or one riding with its inspection", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "inspection-key", "user-1"); const raw = await openDB(DB_NAME); const ready = { ...photo("ready", "inspection-key"), state: "ready", inspectionId: "srv-1" }; await raw.put("pending-photos", ready); await raw.put("pending-photos", { ...ready, id: "rejected", lastError: REJECTION }); await raw.put("pending-photos", { ...ready, id: "foreign", ownerUserId: "user-2" }); await raw.put("pending-photos", photo("rides-along", "inspection-key")); await raw.put("pending-photos", { ...photo("draft", "unused"), state: "draft" }); raw.close(); // The inspection itself, plus the ready photo; the rejected one separately. expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 1, foreign: 1 }); }); it("toQueueError keeps the server's status, message and body", async () => { const db = await loadDb(); const { ApiError } = await import("@/lib/auth/api-client"); const body = { statusCode: 413, message: "Datei zu groß" }; const error = db.toQueueError(new ApiError(413, "Datei zu groß", body)); expect(error).toEqual({ status: 413, message: "Datei zu groß", details: body, at: expect.any(String) }); }); it("stores each entry with its key and owner", async () => { const db = await loadDb(); await db.queueVarroaMeasurement( { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", method: "WASHING_METHOD" as const }, "key-1", "user-1", ); const [entry] = await db.getPendingEntries("pending-varroa-measurements"); expect(entry).toMatchObject({ idempotencyKey: "key-1", ownerUserId: "user-1" }); expect(Date.parse(entry.queuedAt)).not.toBeNaN(); }); it("lists only the current user's entries, oldest first, with the server's message", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "key-1", "user-1"); await db.queueFeeding(FEEDING, "key-2", "user-2"); await db.queueTreatment(TREATMENT, "key-3", "user-1"); const [treatment] = await db.getPendingEntries("pending-treatments"); await db.markFailed("pending-treatments", treatment.id, REJECTION); const items = await db.listQueue("user-1"); expect(items).toEqual([ expect.objectContaining({ store: "pending-inspections", label: "Durchsicht" }), expect.objectContaining({ store: "pending-treatments", id: treatment.id, label: "Behandlung", lastError: REJECTION, }), ]); }); it("discardQueueItem deletes exactly that entry", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "key-1", "user-1"); await db.queueInspection(INSPECTION, "key-2", "user-1"); const [first] = await db.listQueue("user-1"); await db.discardQueueItem(first); const remaining = await db.getPendingEntries("pending-inspections"); expect(remaining.map((entry) => entry.idempotencyKey)).toEqual(["key-2"]); }); it("discarding a queued inspection discards its photos and no others", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "inspection-key", "user-1"); const raw = await openDB(DB_NAME); await raw.put("pending-photos", photo("p1", "inspection-key")); await raw.put("pending-photos", photo("p2", "other-inspection-key")); const [item] = await db.listQueue("user-1"); expect(item.photoCount).toBe(1); await db.discardQueueItem(item); expect(await raw.getAllKeys("pending-photos")).toEqual(["p2"]); expect(await db.getPendingEntries("pending-inspections")).toEqual([]); raw.close(); }); it("clearQueueOf removes one owner's entries and photos and keeps everyone else's", async () => { const db = await loadDb(); await db.queueInspection(INSPECTION, "key-1", "user-1"); await db.queueFeeding(FEEDING, "key-2", "user-1"); await db.queueInspection(INSPECTION, "key-3", "user-2"); const raw = await openDB(DB_NAME); await raw.put("pending-photos", photo("p1", "key-1", "user-1")); await raw.put("pending-photos", photo("p3", "key-3", "user-2")); await db.clearQueueOf("user-1"); expect(await db.summarizeQueue("user-1")).toEqual({ own: 0, failed: 0, foreign: 1 }); const [left] = await db.getPendingEntries("pending-inspections"); expect(left.idempotencyKey).toBe("key-3"); expect(await raw.getAllKeys("pending-photos")).toEqual(["p3"]); raw.close(); }); }); describe("bulk entries in the offline queue", () => { const BULK_TREATMENT = { date: "2026-10-14T00:00:00.000Z", product: "Oxuvar", batchNumber: "Ch-123", colonyIds: ["c-1", "c-2", "c-3"], }; const BULK_VARROA = { date: "2026-10-14T00:00:00.000Z", method: "NATURAL_MITE_FALL", entries: [ { colonyId: "c-1", miteCount: 4 }, { colonyId: "c-3", miteCount: 9 }, ], }; const INVALID_COLONIES = { status: 400, message: "Einige Völker sind nicht (mehr) erfassbar", details: { invalidColonyIds: ["c-3"] }, at: "2026-10-14T09:00:00.000Z", }; beforeEach(() => { globalThis.indexedDB = new IDBFactory(); }); async function onlyBulkEntry() { const raw = await openDB(DB_NAME); const [entry] = await raw.getAll("pending-bulk"); raw.close(); return entry; } it("queueBulk stores exactly one entry with kind, input, key and owner", async () => { const db = await loadDb(); await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1"); expect(await onlyBulkEntry()).toEqual({ id: 1, kind: "treatments", input: BULK_TREATMENT, idempotencyKey: "bulk-key-0000000001", ownerUserId: "user-1", queuedAt: expect.any(String), }); expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 }); }); it("lists a bulk entry under its own name", async () => { const db = await loadDb(); await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1"); const [item] = await db.listQueue("user-1"); expect(item).toMatchObject({ store: "pending-bulk", label: "Varroamessung für mehrere Völker", }); expect(item.resendWithout).toBeUndefined(); }); it("names the rejected colonies by code from the loaded packs", async () => { const db = await loadDb(); await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1"); const raw = await openDB(DB_NAME); await raw.put("offline-packs", { apiaryId: "apiary-1", ownerUserId: "user-1", savedAt: "2026-10-14T08:00:00.000Z", pack: { apiary: { id: "apiary-1", name: "Waldrand" }, colonies: [ { id: "c-1", code: "V-1", apiaryId: "apiary-1", status: "ACTIVE" }, { id: "c-3", code: "V-3", apiaryId: "apiary-1", status: "DISSOLVED" }, ], }, }); raw.close(); const entry = await onlyBulkEntry(); await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES); const [item] = await db.listQueue("user-1"); expect(item.resendWithout).toEqual({ colonyIds: ["c-3"], codes: ["V-3"] }); }); it("offers no resend when every colony of the entry was rejected", async () => { const db = await loadDb(); await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1"); const entry = await onlyBulkEntry(); await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES); const [item] = await db.listQueue("user-1"); expect(item.resendWithout).toBeUndefined(); }); it("retryBulkWithout resends the remaining colonies with the same key and clears the rejection", async () => { const db = await loadDb(); await db.queueBulk("treatments", BULK_TREATMENT, "bulk-key-0000000001", "user-1"); const entry = await onlyBulkEntry(); await db.markFailed("pending-bulk", entry.id, INVALID_COLONIES); await db.retryBulkWithout(entry.id, ["c-3"]); expect(await onlyBulkEntry()).toEqual({ ...entry, input: { ...BULK_TREATMENT, colonyIds: ["c-1", "c-2"] }, lastError: undefined, }); expect((await onlyBulkEntry()).idempotencyKey).toBe("bulk-key-0000000001"); }); it("retryBulkWithout drops a varroa colony with its own count", async () => { const db = await loadDb(); await db.queueBulk("varroa", BULK_VARROA, "bulk-key-0000000001", "user-1"); const entry = await onlyBulkEntry(); await db.retryBulkWithout(entry.id, ["c-3"]); expect((await onlyBulkEntry()).input).toEqual({ ...BULK_VARROA, entries: [{ colonyId: "c-1", miteCount: 4 }], }); }); it("retryBulkWithout removes an entry that would be left without colonies", async () => { const db = await loadDb(); await db.queueBulk("treatments", { ...BULK_TREATMENT, colonyIds: ["c-3"] }, "bulk-key-0000000001", "user-1"); const entry = await onlyBulkEntry(); await db.retryBulkWithout(entry.id, ["c-3"]); expect(await onlyBulkEntry()).toBeUndefined(); }); it("discardQueueItem deletes a bulk entry", async () => { const db = await loadDb(); await db.queueBulk("feedings", BULK_TREATMENT, "bulk-key-0000000001", "user-1"); const [item] = await db.listQueue("user-1"); await db.discardQueueItem(item); expect(await onlyBulkEntry()).toBeUndefined(); }); }); describe("voice notes in the offline queue", () => { beforeEach(() => { globalThis.indexedDB = new IDBFactory(); }); function note(id: string, ownerUserId: string, lastError?: typeof REJECTION) { return { id, ownerUserId, blob: new Blob(["audio"]), mime: "audio/webm;codecs=opus", durationSec: 30, colonyId: "colony-1", inspectionDate: "2026-10-16", createdAt: "2026-10-16T08:00:00.000Z", uploadIdempotencyKey: `upload-${id}`, state: "recorded" as const, ...(lastError ? { lastError } : {}), }; } async function storeNotes() { const db = await loadDb(); await db.queueInspection(INSPECTION, "key-1", "user-1"); const raw = await openDB(DB_NAME); await raw.put("pending-voice", note("v1", "user-1")); await raw.put("pending-voice", note("v2", "user-1", REJECTION)); await raw.put("pending-voice", note("v3", "user-2")); raw.close(); return db; } it("summarizeQueue counts waiting, rejected and foreign voice notes", async () => { const db = await storeNotes(); expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 1, foreign: 1, voice: 2, voiceWaiting: 1, }); expect(await db.summarizeQueue("user-2")).toEqual({ own: 1, failed: 0, foreign: 3, voice: 1, voiceWaiting: 1, }); }); it("listQueue lists the user's voice notes and never another account's", async () => { const db = await storeNotes(); const items = (await db.listQueue("user-1")).filter((item) => item.store === "pending-voice"); expect(items.map((item) => item.id)).toEqual(["v1", "v2"]); expect(items[0]).toMatchObject({ label: "Sprachnotiz", voice: { colonyId: "colony-1", inspectionDate: "2026-10-16", durationSec: 30 }, }); expect(items[1].lastError).toMatchObject({ status: 400 }); }); });