Files
ApiCore/ApiCore-Frontend/src/lib/offline/sw-routes.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

273 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it } from "vitest";
type OfflineFallback = { kind: "redirect"; to: string } | { kind: "cached-path"; path: string };
type NavigationPlan =
| { kind: "browser" }
| { kind: "cache-first" }
| { kind: "network-first"; timeoutMs: number; redirectOnTimeout: boolean };
interface SwRoutes {
SHELL_ROUTES: readonly string[];
offlineFallbackFor: (url: URL) => OfflineFallback;
offlineHomeFor: (url: URL) => string;
navigationPlanFor: (url: URL) => NavigationPlan;
isShellRoute: (pathname: string) => boolean;
extractAssetUrls: (html: string) => string[];
isCacheableApiPath: (pathname: string) => boolean;
shouldCacheApi: (url: URL, apiOrigin: string | null) => boolean;
apiOriginFrom: (config: unknown) => string | null;
isRoutePayload: (url: URL) => boolean;
buildAssetsToPrune: (
cached: { url: string; date: number | null }[],
current: readonly string[],
now: number,
) => string[];
}
// The service worker's routing rules are a plain script (public/), loaded by
// the worker with importScripts - here as CommonJS.
const require = createRequire(import.meta.url);
// Vitest runs from the frontend root.
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
const routes = require(path.join(PUBLIC_DIR, "sw-routes.js")) as SwRoutes;
const APP = "https://apicore.example";
const API = "https://apicorebackend.example";
const DAY_MS = 24 * 60 * 60 * 1000;
function fallbackFor(path: string): OfflineFallback {
return routes.offlineFallbackFor(new URL(path, APP));
}
function planFor(path: string): NavigationPlan {
return routes.navigationPlanFor(new URL(path, APP));
}
function cachesApi(path: string, origin = API): boolean {
return routes.shouldCacheApi(new URL(path, origin), API);
}
describe("service worker routes", () => {
it("maps /colonies/abc to /offline/colony?id=abc and encodes ids", () => {
expect(fallbackFor("/colonies/abc")).toEqual({
kind: "redirect",
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc",
});
expect(fallbackFor("/colonies/abc/")).toEqual({
kind: "redirect",
to: "/offline/colony?id=abc&from=%2Fcolonies%2Fabc%2F",
});
expect(fallbackFor("/apiaries/xyz")).toEqual({
kind: "redirect",
to: "/offline/apiary?id=xyz&from=%2Fapiaries%2Fxyz",
});
expect(fallbackFor("/colonies/a%20b%26c%3Dd")).toEqual({
kind: "redirect",
to: "/offline/colony?id=a%20b%26c%3Dd&from=%2Fcolonies%2Fa%2520b%2526c%253Dd",
});
expect(fallbackFor("/colonies/a&b=c#x")).toEqual({
kind: "redirect",
to: "/offline/colony?id=a%26b%3Dc&from=%2Fcolonies%2Fa%26b%3Dc",
});
});
it("serves /inspections/new?colonyId=x from the cached shell path", () => {
expect(fallbackFor("/inspections/new?colonyId=x&round=apiary-1")).toEqual({
kind: "cached-path",
path: "/inspections/new",
});
expect(fallbackFor("/offline/colony?id=abc")).toEqual({
kind: "cached-path",
path: "/offline/colony",
});
expect(fallbackFor("/dashboard/")).toEqual({ kind: "cached-path", path: "/dashboard" });
for (const route of [
"/",
"/login",
"/dashboard",
"/offline",
"/offline/apiary",
"/offline/colony",
"/inspections/new",
"/varroa/new",
"/treatments/new",
"/feedings/new",
"/swarm-control/new",
]) {
expect(routes.isShellRoute(route)).toBe(true);
}
expect(routes.SHELL_ROUTES).toHaveLength(11);
});
it("unknown routes go to /offline and remember where the user wanted to go", () => {
expect(fallbackFor("/statistics?year=2026")).toEqual({
kind: "redirect",
to: "/offline?from=%2Fstatistics%3Fyear%3D2026",
});
for (const path of [
"/colonies",
"/colonies/new",
"/apiaries/new",
"/apiaries/map",
"/colonies/abc/edit",
"/colonies/%E0%A4%A",
"/harvests/new",
]) {
const fallback = fallbackFor(path);
expect(fallback.kind).toBe("redirect");
expect(fallback.kind === "redirect" && fallback.to.startsWith("/offline?from=")).toBe(true);
}
expect(routes.isShellRoute("/statistics")).toBe(false);
expect(routes.offlineHomeFor(new URL("/queens/q-1", APP))).toBe("/offline?from=%2Fqueens%2Fq-1");
});
it("plans each navigation by its route", () => {
expect(planFor("/offline")).toEqual({ kind: "cache-first" });
expect(planFor("/offline/apiary?id=a-1")).toEqual({ kind: "cache-first" });
expect(planFor("/offline/colony?id=c-1")).toEqual({ kind: "cache-first" });
// Shell pages answer from the cache after 4 s - without one, the network
// keeps its chance (no dead end on a slow line).
expect(planFor("/inspections/new?colonyId=c-1")).toEqual({
kind: "network-first",
timeoutMs: 4000,
redirectOnTimeout: false,
});
expect(planFor("/")).toEqual({ kind: "network-first", timeoutMs: 4000, redirectOnTimeout: false });
// A scanned QR code at the apiary: the offline view after 10 s.
expect(planFor("/colonies/c-1")).toEqual({
kind: "network-first",
timeoutMs: 10000,
redirectOnTimeout: true,
});
expect(planFor("/apiaries/a-1")).toEqual({
kind: "network-first",
timeoutMs: 10000,
redirectOnTimeout: true,
});
expect(planFor("/statistics")).toEqual({
kind: "network-first",
timeoutMs: 15000,
redirectOnTimeout: true,
});
});
it("leaves navigations to /api/ to the browser", () => {
expect(planFor("/api/runtime-config")).toEqual({ kind: "browser" });
expect(planFor("/api/v1/documents/d-1/download")).toEqual({ kind: "browser" });
});
it("never caches /api/v1/auth/*", () => {
for (const path of [
"/api/v1/auth/me",
"/api/v1/auth/refresh",
"/api/v1/auth/sso/status",
"/api/v1/auth",
"/api/v1/Auth/me",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/authors")).toBe(true);
});
it("never caches what the offline packs answer, so the pack answers offline", () => {
for (const path of [
"/api/v1/colonies",
"/api/v1/colonies/",
"/api/v1/colonies?status=ACTIVE",
"/api/v1/colonies/c-1/inspections",
"/api/v1/apiaries/a-1/colony-overview",
"/api/v1/apiaries/a-1/offline-pack",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/colonies/c-1")).toBe(true);
expect(cachesApi("/api/v1/tasks")).toBe(true);
});
it("never caches apiaries themselves - they carry coordinates and the address", () => {
for (const path of ["/api/v1/apiaries", "/api/v1/apiaries/", "/api/v1/apiaries/a-1", "/api/v1/apiaries/a-1/"]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/apiaries/a-1/weather")).toBe(true);
});
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
for (const path of [
"/api/v1/export/apiaries.json",
"/api/v1/export/treatment-log.pdf",
"/api/v1/export/colonies.csv",
"/api/v1/Export/apiaries.json",
"/api/v1/backup/export",
"/api/v1/backup",
]) {
expect(cachesApi(path)).toBe(false);
}
expect(cachesApi("/api/v1/exporters")).toBe(true);
});
it("caches only answers of the configured backend", () => {
expect(cachesApi("/api/v1/tasks", "https://elsewhere.example")).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/v1/tasks", API), null)).toBe(false);
expect(routes.shouldCacheApi(new URL("/api/runtime-config", APP), APP)).toBe(false);
expect(routes.shouldCacheApi(new URL("/_next/static/chunks/main.js", APP), APP)).toBe(false);
expect(routes.apiOriginFrom({ apiUrl: `${API}/api/v1` })).toBe(API);
expect(routes.apiOriginFrom({ apiUrl: "kein url" })).toBeNull();
expect(routes.apiOriginFrom(null)).toBeNull();
expect(routes.isCacheableApiPath("/api/v1/apiaries/a-1")).toBe(false);
expect(routes.isCacheableApiPath("/api/v1/tasks")).toBe(true);
});
it("recognises route payloads, which are never kept", () => {
expect(routes.isRoutePayload(new URL("/dashboard?_rsc=1x2y", APP))).toBe(true);
expect(routes.isRoutePayload(new URL("/dashboard", APP))).toBe(false);
});
it("extracts _next/static asset urls", () => {
const html = `<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/_next/static/css/app-1a2b.css" data-precedence="next"/>
<link rel="preload" href="/_next/static/media/inter-3c4d.p.woff2" as="font" crossorigin=""/>
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
<script src='/_next/static/chunks/app/page-7a8b.js?dpl=abc' async></script>
<script src="https://cdn.example/_next/static/chunks/foreign.js"></script>
<script src="/_next/static/chunks/webpack-5e6f.js" async=""></script>
</head><body><script>self.__next_f.push([1,"0:[\\"/_next/static/chunks/lazy-9c0d.js\\"]"])</script></body></html>`;
expect(routes.extractAssetUrls(html).sort()).toEqual([
"/_next/static/chunks/app/page-7a8b.js?dpl=abc",
"/_next/static/chunks/lazy-9c0d.js",
"/_next/static/chunks/webpack-5e6f.js",
"/_next/static/css/app-1a2b.css",
"/_next/static/media/inter-3c4d.p.woff2",
]);
expect(routes.extractAssetUrls("<html><body>Keine Assets</body></html>")).toEqual([]);
});
it("prunes build assets of old builds, keeping current and recently used ones", () => {
const now = Date.parse("2026-10-14T12:00:00.000Z");
const cached = [
{ url: `${APP}/_next/static/chunks/current.js`, date: now - 30 * DAY_MS },
{ url: `${APP}/_next/static/chunks/old.js`, date: now - 8 * DAY_MS },
{ url: `${APP}/_next/static/chunks/recent-lazy.js`, date: now - DAY_MS },
{ url: `${APP}/_next/static/chunks/undated.js`, date: null },
{ url: `${APP}/icon.svg`, date: now - 90 * DAY_MS },
];
expect(
routes.buildAssetsToPrune(cached, ["/_next/static/chunks/current.js"], now).sort(),
).toEqual([`${APP}/_next/static/chunks/old.js`, `${APP}/_next/static/chunks/undated.js`]);
});
it("sw.js imports sw-routes.js under its content version, so every change reaches the phones", () => {
const worker = readFileSync(path.join(PUBLIC_DIR, "sw.js"), "utf8");
const rules = readFileSync(path.join(PUBLIC_DIR, "sw-routes.js"), "utf8").replace(/\r\n/g, "\n");
const version = createHash("sha256").update(rules).digest("hex").slice(0, 12);
expect(worker).toContain(`const SW_ROUTES_VERSION = "${version}";`);
expect(worker).toContain("importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);");
});
});