Files
ApiCore/ApiCore-Frontend/src/lib/auth/session-cleanup.spec.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

165 lines
6.2 KiB
TypeScript

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<typeof vi.fn>;
let postMessage: ReturnType<typeof vi.fn>;
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();
});
});
});