import "fake-indexeddb/auto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError, apiFetch } from "@/lib/auth/api-client"; import { getDb } from "@/lib/offline/db"; import { PACK_AUTO_REFRESH_AFTER_MS, PACK_MAX_AGE_MS, PACK_STALE_MS, deleteAllPacks, findPackForColony, getPack, isPackStale, listPacks, livingColoniesFromPacks, loadPack, packAgeText, purgeOldPacks, refreshPacksAfterSync, } from "./offline-pack"; import { DAY_MS, HOUR_MS, MINUTE_MS, offlinePack, packColony, storedPack, } from "./offline-pack.fixtures"; import type { StoredPack } from "@/lib/offline/db"; vi.mock("@/lib/auth/api-client", async (importOriginal) => ({ ...(await importOriginal()), apiFetch: vi.fn(), })); const NOW = Date.parse("2026-10-13T12:00:00.000Z"); function savedAgo(ms: number): string { return new Date(NOW - ms).toISOString(); } async function putPacks(...packs: StoredPack[]): Promise { const db = await getDb(); await Promise.all(packs.map((pack) => db.put("offline-packs", pack))); } async function storedApiaryIds(): Promise { const db = await getDb(); return (await db.getAllKeys("offline-packs")).sort(); } describe("offline packs", () => { beforeEach(async () => { vi.mocked(apiFetch).mockReset(); await deleteAllPacks(); }); afterEach(() => { vi.useRealTimers(); }); it("stores the pack with owner and time", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); const pack = offlinePack({ apiaryId: "apiary-1" }); vi.mocked(apiFetch).mockResolvedValue(pack); const stored = await loadPack("apiary-1", "user-1"); expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-1/offline-pack"); expect(stored).toEqual({ apiaryId: "apiary-1", ownerUserId: "user-1", savedAt: new Date(NOW).toISOString(), pack, }); expect(await getPack("apiary-1")).toEqual(stored); }); it("never stores coordinates or an address, even if the server sent them", async () => { const pack = offlinePack({ apiaryId: "apiary-1" }); vi.mocked(apiFetch).mockResolvedValue({ ...pack, apiary: { ...pack.apiary, latitude: 52.1, longitude: 13.4, address: "Feldweg 3", notes: "Schlüssel unterm Stein" }, }); await loadPack("apiary-1", "user-1"); const kept = await getPack("apiary-1"); expect(Object.keys(kept?.pack.apiary ?? {}).sort()).toEqual(["id", "location", "name", "status"]); }); it("refuses a pack it does not understand instead of storing it", async () => { vi.mocked(apiFetch).mockResolvedValue({ ...offlinePack(), version: 2 }); await expect(loadPack("apiary-1", "user-1")).rejects.toThrow(); expect(await getPack("apiary-1")).toBeNull(); }); it("encodes the apiary id in the request", async () => { vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "a/b" })); await loadPack("a/b", "user-1"); expect(apiFetch).toHaveBeenCalledWith("/apiaries/a%2Fb/offline-pack"); }); it("findPackForColony", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1", colonies: [packColony("colony-1", "A1")] })), storedPack(offlinePack({ apiaryId: "apiary-2", colonies: [packColony("colony-2", "B1")] })), storedPack(offlinePack({ apiaryId: "apiary-3", colonies: [packColony("colony-3", "C1")] }), { ownerUserId: "user-2", }), ); expect((await findPackForColony("colony-2", "user-1"))?.apiaryId).toBe("apiary-2"); // Another account's pack is never handed out. expect(await findPackForColony("colony-3", "user-1")).toBeNull(); expect(await findPackForColony("colony-unknown", "user-1")).toBeNull(); }); it("findPackForColony prefers the newest pack when a colony moved between loaded apiaries", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-old", colonies: [packColony("colony-1", "A1")] }), { savedAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(), }), storedPack(offlinePack({ apiaryId: "apiary-new", colonies: [packColony("colony-1", "A1")] }), { savedAt: new Date(Date.now() - HOUR_MS).toISOString(), }), ); expect((await findPackForColony("colony-1", "user-1"))?.apiaryId).toBe("apiary-new"); }); it("lists only the owner's packs", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1" })), storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }), ); expect((await listPacks("user-1")).map((pack) => pack.apiaryId)).toEqual(["apiary-1"]); }); it("never hands out a pack older than 14 days, even before it is purged", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: new Date(Date.now() - PACK_MAX_AGE_MS - MINUTE_MS).toISOString(), }), ); expect(await listPacks("user-1")).toEqual([]); expect(await getPack("apiary-1")).toBeNull(); }); it("purges packs older than 14 days", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(15 * DAY_MS) }), storedPack(offlinePack({ apiaryId: "apiary-edge" }), { savedAt: savedAgo(PACK_MAX_AGE_MS) }), storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(13 * DAY_MS) }), ); const purged = await purgeOldPacks(NOW); expect(purged).toBe(1); expect(await storedApiaryIds()).toEqual(["apiary-edge", "apiary-young"]); }); it("stale after 24 h", () => { const pack = offlinePack(); expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS - 1) }), NOW)).toBe(false); expect(isPackStale(storedPack(pack, { savedAt: savedAgo(PACK_STALE_MS) }), NOW)).toBe(true); expect(isPackStale(storedPack(pack, { savedAt: "kaputt" }), NOW)).toBe(true); }); it("deletes every pack of every account", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1" })), storedPack(offlinePack({ apiaryId: "apiary-2" }), { ownerUserId: "user-2" }), ); await deleteAllPacks(); expect(await storedApiaryIds()).toEqual([]); }); it("says how long ago a pack was loaded", () => { const at = (ms: number) => packAgeText(savedAgo(ms), NOW); expect(at(20 * 1000)).toBe("vor weniger als einer Minute"); expect(at(MINUTE_MS)).toBe("vor 1 Minute"); expect(at(5 * MINUTE_MS)).toBe("vor 5 Minuten"); expect(at(HOUR_MS)).toBe("vor 1 Stunde"); expect(at(3 * HOUR_MS + 10 * MINUTE_MS)).toBe("vor 3 Stunden"); expect(at(DAY_MS)).toBe("vor 1 Tag"); expect(at(2 * DAY_MS + HOUR_MS)).toBe("vor 2 Tagen"); }); describe("refreshPacksAfterSync", () => { beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(NOW); }); it("quietly reloads the owner's packs older than 1 h and leaves younger ones alone", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(PACK_AUTO_REFRESH_AFTER_MS + MINUTE_MS), }), storedPack(offlinePack({ apiaryId: "apiary-young" }), { savedAt: savedAgo(30 * MINUTE_MS) }), storedPack(offlinePack({ apiaryId: "apiary-foreign" }), { ownerUserId: "user-2", savedAt: savedAgo(5 * HOUR_MS), }), ); vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-old", apiaryName: "Neu" })); await refreshPacksAfterSync("user-1", NOW); expect(apiFetch).toHaveBeenCalledTimes(1); expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-old/offline-pack"); const refreshed = await getPack("apiary-old"); expect(refreshed?.pack.apiary.name).toBe("Neu"); expect(refreshed?.savedAt).toBe(new Date(NOW).toISOString()); }); it("after a rejection reloads a 10-min-old pack holding the colony, not a 2-min-old one", async () => { await putPacks( storedPack( offlinePack({ apiaryId: "apiary-ten", colonies: [packColony("colony-3", "V-03")] }), { savedAt: savedAgo(10 * MINUTE_MS) }, ), storedPack( offlinePack({ apiaryId: "apiary-two", colonies: [packColony("colony-3", "V-03")] }), { savedAt: savedAgo(2 * MINUTE_MS) }, ), storedPack( offlinePack({ apiaryId: "apiary-other", colonies: [packColony("colony-9", "X-09")] }), { savedAt: savedAgo(10 * MINUTE_MS) }, ), ); vi.mocked(apiFetch).mockResolvedValue(offlinePack({ apiaryId: "apiary-ten", colonies: [] })); await refreshPacksAfterSync("user-1", NOW, undefined, { force: true, colonyIds: ["colony-3"], }); expect(apiFetch).toHaveBeenCalledTimes(1); expect(apiFetch).toHaveBeenCalledWith("/apiaries/apiary-ten/offline-pack"); // The colony ended on the server: the reloaded pack no longer offers it. expect((await getPack("apiary-ten"))?.pack.colonies).toEqual([]); }); it("drops a pack the user may no longer read or whose apiary is gone", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-gone" }), { savedAt: savedAgo(2 * HOUR_MS) }), storedPack(offlinePack({ apiaryId: "apiary-forbidden" }), { savedAt: savedAgo(2 * HOUR_MS) }), storedPack(offlinePack({ apiaryId: "apiary-broken" }), { savedAt: savedAgo(2 * HOUR_MS) }), ); vi.mocked(apiFetch).mockImplementation(async (path: string) => { if (path.includes("apiary-gone")) throw new ApiError(404, "Apiary not found"); if (path.includes("apiary-forbidden")) throw new ApiError(403, "Forbidden"); throw new ApiError(500, "Internal server error"); }); await refreshPacksAfterSync("user-1", NOW); // A server failure keeps the pack: it is still the best the device knows. expect(await storedApiaryIds()).toEqual(["apiary-broken"]); }); it("stops at the first network error and never throws", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }), storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }), ); vi.mocked(apiFetch).mockRejectedValue(new TypeError("Failed to fetch")); await expect(refreshPacksAfterSync("user-1", NOW)).resolves.toBeUndefined(); expect(apiFetch).toHaveBeenCalledTimes(1); expect(await storedApiaryIds()).toEqual(["apiary-1", "apiary-2"]); }); it("stops once the sync run is cancelled", async () => { await putPacks( storedPack(offlinePack({ apiaryId: "apiary-1" }), { savedAt: savedAgo(2 * HOUR_MS) }), storedPack(offlinePack({ apiaryId: "apiary-2" }), { savedAt: savedAgo(2 * HOUR_MS) }), ); let cancelled = false; vi.mocked(apiFetch).mockImplementation(async (path: string) => { cancelled = true; return offlinePack({ apiaryId: path.split("/")[2] }); }); await refreshPacksAfterSync("user-1", NOW, () => cancelled); expect(apiFetch).toHaveBeenCalledTimes(1); }); it("purges packs older than 14 days on the way", async () => { await putPacks(storedPack(offlinePack({ apiaryId: "apiary-old" }), { savedAt: savedAgo(20 * DAY_MS) })); await refreshPacksAfterSync("user-1", NOW); expect(apiFetch).not.toHaveBeenCalled(); expect(await storedApiaryIds()).toEqual([]); }); }); }); describe("livingColoniesFromPacks order", () => { it("sorts colony codes naturally (V-2 before V-10)", () => { const pack = storedPack( offlinePack({ colonies: [packColony("c1", "V-10"), packColony("c2", "V-2"), packColony("c3", "V-010")], }), ); expect(livingColoniesFromPacks([pack]).map((colony) => colony.code)).toEqual(["V-2", "V-010", "V-10"]); }); });