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]>
273 lines
11 KiB
TypeScript
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}`);");
|
|
});
|
|
});
|