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]>
165 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|