import { afterEach, describe, expect, it, vi } from "vitest"; import { createCache, type Cache } from "../src/services/cache.js"; import type { FetchLike } from "../src/services/http.js"; import { assertOfficialNavigationUrl, buildPegelOnlineUrl, createOfficialJsonAdapter, getNavigationData, type NavigationDataAdapter, type WaterLevel } from "../src/services/navigation-data.js"; const openCaches: Cache[] = []; afterEach(async () => { await Promise.all(openCaches.splice(0).map((cache) => cache.close())); vi.restoreAllMocks(); }); describe("official navigation data", () => { it("normalizes current PEGELONLINE water levels and caches the result", async () => { const cache = memoryCache(); const fetcher = vi.fn(async () => new Response( JSON.stringify([ { uuid: "edfdf747-be92-462f-87ed-53d228a33172", number: "3970010", shortname: "EMDEN NEUE SEESCHLEUSE", agency: "STANDORT EMDEN", longitude: 7.186348, latitude: 53.336781, km: 40.45, water: { shortname: "EMS", longname: "EMS" }, timeseries: [ { shortname: "W", unit: "cm", currentMeasurement: { timestamp: "2026-07-19T13:18:00+02:00", value: 564, stateMnwMhw: "normal", stateNswHsw: "unknown" } } ] } ]), { status: 200, headers: { "content-type": "application/json" } } )) as unknown as FetchLike; const now = () => new Date("2026-07-19T11:20:00.000Z"); const first = await getNavigationData( { waterways: [" EMS ", "EMS"] }, { cache, fetcher, now } ); const second = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher, now } ); expect(fetcher).toHaveBeenCalledTimes(1); expect(String(vi.mocked(fetcher).mock.calls[0]?.[0])).toContain("waters=EMS"); expect(first.waterLevels).toEqual([ expect.objectContaining({ stationId: "edfdf747-be92-462f-87ed-53d228a33172", stationName: "EMDEN NEUE SEESCHLEUSE", waterway: "EMS", value: 564, unit: "cm", stateMnwMhw: "normal" }) ]); expect(first.sources.map((source) => source.state)).toEqual([ "live", "not-configured", "not-configured" ]); expect(second.sources[0]?.state).toBe("cached"); }); it("uses last-good data when a live source fails", async () => { const cache = memoryCache(); let sourceAvailable = true; const adapter: NavigationDataAdapter = { kind: "water-levels", id: "test-wsv-levels", label: "Test WSV levels", sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi", freshTtlMs: 1, staleTtlMs: 60_000, async load() { if (!sourceAvailable) { throw new Error("WSV test outage"); } return [waterLevelFixture()]; } }; const live = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } } ); sourceAvailable = false; await new Promise((resolve) => setTimeout(resolve, 5)); const fallback = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } } ); expect(live.sources[0]?.state).toBe("live"); expect(fallback.waterLevels).toEqual([waterLevelFixture()]); expect(fallback.sources[0]).toEqual( expect.objectContaining({ state: "stale", warning: expect.stringContaining("letzter erfolgreicher Stand") }) ); }); it("times out an adapter and returns an explicit unavailable state", async () => { const cache = memoryCache(); const adapter: NavigationDataAdapter = { kind: "water-levels", id: "slow-wsv-source", label: "Slow official source", sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi", load: () => new Promise(() => undefined) }; const result = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter }, timeoutMs: 5 } ); expect(result.waterLevels).toEqual([]); expect(result.sources[0]).toEqual( expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("Zeitlimit") }) ); }); it("does not download the nationwide station list without a route filter", async () => { const cache = memoryCache(); const fetcher = vi.fn() as unknown as FetchLike; const result = await getNavigationData({}, { cache, fetcher }); expect(fetcher).not.toHaveBeenCalled(); expect(result.sources[0]).toEqual( expect.objectContaining({ state: "not-configured", warning: expect.stringContaining("Stations-UUID") }) ); }); it("supports explicitly configured adapters for documented official JSON endpoints", async () => { const cache = memoryCache(); const level = waterLevelFixture(); const adapter = createOfficialJsonAdapter({ kind: "water-levels", id: "configured-pegelonline-feed", label: "Configured PEGELONLINE feed", sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi", buildUrl: () => "https://pegelonline.wsv.de/webservices/rest-api/v2/stations.json?waters=EMS", parse: (payload) => (payload as { levels: WaterLevel[] }).levels }); const fetcher = vi.fn(async () => new Response(JSON.stringify({ levels: [level] }), { status: 200 })) as unknown as FetchLike; const result = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher, adapters: { waterLevels: adapter } } ); expect(result.waterLevels).toEqual([level]); expect(result.sources.find((source) => source.kind === "water-levels")?.state).toBe("live"); }); it("rejects unofficial or insecure configured endpoints", () => { expect(() => assertOfficialNavigationUrl("http://www.elwis.de/feed.json")).toThrow( /offizielle HTTPS-Quellen/ ); expect(() => assertOfficialNavigationUrl("https://elwis.de.example.org/feed.json")).toThrow( /offizielle HTTPS-Quellen/ ); expect(() => createOfficialJsonAdapter({ kind: "notices", id: "unofficial", label: "Unofficial", sourceUrl: "https://example.org/feed", buildUrl: () => "https://example.org/feed", parse: () => [] }) ).toThrow(/offizielle HTTPS-Quellen/); }); it("blocks unofficial network requests made by a custom adapter", async () => { const cache = memoryCache(); const networkFetcher = vi.fn() as unknown as FetchLike; const adapter: NavigationDataAdapter = { kind: "water-levels", id: "misconfigured-custom-adapter", label: "Misconfigured adapter", sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi", async load(_query, { fetcher }) { await fetcher("https://example.org/not-official.json"); return []; } }; const result = await getNavigationData( { waterways: ["EMS"] }, { cache, fetcher: networkFetcher, adapters: { waterLevels: adapter } } ); expect(networkFetcher).not.toHaveBeenCalled(); expect(result.sources[0]).toEqual( expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("offizielle HTTPS-Quellen") }) ); }); it("builds a stable, bounded PEGELONLINE query", () => { const url = new URL( buildPegelOnlineUrl({ stationIds: ["b", "a", "a"], waterways: ["RHEIN", "EMS"] }) ?? "" ); expect(url.origin).toBe("https://pegelonline.wsv.de"); expect(url.searchParams.get("ids")).toBe("a,b"); expect(url.searchParams.get("waters")).toBe("EMS,RHEIN"); expect(url.searchParams.get("timeseries")).toBe("W"); expect(url.searchParams.get("includeCurrentMeasurement")).toBe("true"); }); }); function memoryCache(): Cache { const cache = createCache(); openCaches.push(cache); return cache; } function waterLevelFixture(): WaterLevel { return { stationId: "station-1", stationNumber: "3970010", stationName: "EMDEN NEUE SEESCHLEUSE", waterway: "EMS", waterwayKm: 40.45, latitude: 53.336781, longitude: 7.186348, value: 564, unit: "cm", measuredAt: "2026-07-19T13:18:00+02:00", stateMnwMhw: "normal", stateNswHsw: "unknown", agency: "STANDORT EMDEN", sourceUrl: "https://pegelonline.wsv.de/webservices/rest-api/v2/stations/station-1.json" }; }