261 lines
8.6 KiB
TypeScript
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"
|
|
};
|
|
}
|