Files
watermaps/apps/api/tests/navigation-data.test.ts
2026-07-24 11:29:24 +02:00

261 lines
8.6 KiB
TypeScript

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<WaterLevel> = {
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<WaterLevel> = {
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<WaterLevel>({
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<WaterLevel> = {
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"
};
}