import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { deleteAllPacks } from "@/lib/apiaries/offline-pack"; import { clearAllFormDrafts } from "@/lib/forms/draft-storage"; import { clearQueueOf } from "@/lib/offline/db"; import { forgetHandOversExcept } from "@/lib/photos/handover"; import { discardDraftPhotosExcept } from "@/lib/photos/store"; import { clearAllRounds } from "@/lib/rounds/round-state"; import { clearOwnerlessEntries } from "./legacy-queue"; import { removeOtherUsersData, restoreOfflineShell, wipeCacheStorage, wipeDeviceData, } from "./session-cleanup"; vi.mock("@/lib/apiaries/offline-pack", () => ({ deleteAllPacks: vi.fn().mockResolvedValue(undefined) })); vi.mock("@/lib/forms/draft-storage", () => ({ clearAllFormDrafts: vi.fn() })); vi.mock("@/lib/offline/db", () => ({ clearQueueOf: vi.fn().mockResolvedValue(undefined) })); vi.mock("@/lib/photos/handover", () => ({ forgetHandOversExcept: vi.fn() })); vi.mock("@/lib/photos/store", () => ({ discardDraftPhotosExcept: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/rounds/round-state", () => ({ clearAllRounds: vi.fn() })); vi.mock("./legacy-queue", () => ({ clearOwnerlessEntries: vi.fn().mockResolvedValue(undefined) })); let cacheKeys: string[]; let deleteCache: ReturnType; let postMessage: ReturnType; function stubServiceWorker(replyWith: "done" | "silence"): void { postMessage = vi.fn((_message: unknown, [port]: MessagePort[]) => { if (replyWith === "done") { port.postMessage({ type: "WIPE_CACHES_DONE" }); } }); vi.stubGlobal("navigator", { ...navigator, serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) }, }); } describe("session cleanup", () => { beforeEach(() => { vi.clearAllMocks(); cacheKeys = ["apicore-shell-v4", "apicore-data-v2"]; deleteCache = vi.fn().mockResolvedValue(true); vi.stubGlobal("caches", { keys: async () => cacheKeys, delete: deleteCache }); stubServiceWorker("done"); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("wipeCacheStorage", () => { it("tells the service worker first, then deletes every cache", async () => { await wipeCacheStorage(); expect(postMessage).toHaveBeenCalledWith({ type: "WIPE_CACHES" }, expect.any(Array)); expect(deleteCache).toHaveBeenCalledWith("apicore-shell-v4"); expect(deleteCache).toHaveBeenCalledWith("apicore-data-v2"); expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan( deleteCache.mock.invocationCallOrder[0], ); }); it("does not wait for a service worker that never answers", async () => { vi.useFakeTimers(); stubServiceWorker("silence"); const wiped = wipeCacheStorage(); await vi.advanceTimersByTimeAsync(5_000); await wiped; expect(deleteCache).toHaveBeenCalledTimes(2); }); it("still deletes the caches without a service worker", async () => { vi.stubGlobal("navigator", { ...navigator, serviceWorker: undefined }); Reflect.deleteProperty(navigator, "serviceWorker"); await wipeCacheStorage(); expect(deleteCache).toHaveBeenCalledTimes(2); }); }); describe("wipeDeviceData", () => { it("removes what the person left on the device and keeps their queue by default", async () => { const ok = await wipeDeviceData({ keepDraftsOf: null }); expect(ok).toBe(true); expect(clearAllFormDrafts).toHaveBeenCalled(); expect(clearAllRounds).toHaveBeenCalled(); expect(deleteAllPacks).toHaveBeenCalled(); expect(discardDraftPhotosExcept).toHaveBeenCalledWith(null); expect(forgetHandOversExcept).toHaveBeenCalledWith(null); expect(deleteCache).toHaveBeenCalledTimes(2); expect(clearQueueOf).not.toHaveBeenCalled(); expect(clearOwnerlessEntries).not.toHaveBeenCalled(); }); it("also deletes the queue of the given account and every owner-less entry on request", async () => { await wipeDeviceData({ keepDraftsOf: null, discardQueueOf: "user-1" }); expect(clearQueueOf).toHaveBeenCalledWith("user-1"); expect(clearOwnerlessEntries).toHaveBeenCalled(); }); it("deletes owner-less legacy entries on a user switch without touching anyone's queue", async () => { await wipeDeviceData({ keepDraftsOf: "user-2", discardOwnerless: true }); expect(clearOwnerlessEntries).toHaveBeenCalled(); expect(clearQueueOf).not.toHaveBeenCalled(); }); it("keeps going when one step fails and reports it", async () => { vi.mocked(deleteAllPacks).mockRejectedValueOnce(new Error("IndexedDB blocked")); const ok = await wipeDeviceData({ keepDraftsOf: null }); expect(ok).toBe(false); expect(discardDraftPhotosExcept).toHaveBeenCalled(); expect(deleteCache).toHaveBeenCalledTimes(2); }); }); describe("removeOtherUsersData", () => { it("drops the unsaved photos and hand-overs of every other account", async () => { await removeOtherUsersData("user-2"); expect(discardDraftPhotosExcept).toHaveBeenCalledWith("user-2"); expect(forgetHandOversExcept).toHaveBeenCalledWith("user-2"); expect(deleteAllPacks).not.toHaveBeenCalled(); expect(deleteCache).not.toHaveBeenCalled(); }); }); describe("restoreOfflineShell", () => { it("lets the service worker see the runtime config again, then warms the shell", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response("{}")); vi.stubGlobal("fetch", fetchMock); const warm = vi.fn(); vi.stubGlobal("navigator", { ...navigator, serviceWorker: { getRegistration: async () => ({ active: { postMessage: warm } }) }, }); await restoreOfflineShell(); expect(fetchMock).toHaveBeenCalledWith("/api/runtime-config", expect.anything()); expect(warm).toHaveBeenCalledWith({ type: "WARM_SHELL" }); expect(fetchMock.mock.invocationCallOrder[0]).toBeLessThan(warm.mock.invocationCallOrder[0]); }); it("never throws, whatever is missing", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline"))); Reflect.deleteProperty(navigator, "serviceWorker"); await expect(restoreOfflineShell()).resolves.toBeUndefined(); }); }); });