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 = `
`; 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("Keine Assets")).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}`);"); }); });