Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user