Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildServer } from "../src/app.js";
import { createCache } from "../src/services/cache.js";
import type {
LockOperationInfo,
NavigationDataAdapter
} from "../src/services/navigation-data.js";
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" }
});
describe("Watermaps API", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns app config with map layers", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({ method: "GET", url: "/api/config" });
expect(response.statusCode).toBe(200);
expect(response.json().layers).toHaveLength(3);
await app.close();
});
it("normalizes marine weather responses", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
current: {
wave_height: 0.8,
wave_direction: 280,
wave_period: 4.2
}
})
)
.mockResolvedValueOnce(
jsonResponse({
current: {
wind_speed_10m: 11,
wind_direction_10m: 245,
weather_code: 3,
temperature_2m: 19
}
})
);
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
const response = await app.inject({
method: "GET",
url: "/api/weather/marine?lat=54.18&lon=12.08"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
waveHeightM: 0.8,
windSpeed: 11
});
await app.close();
});
it("returns partial marine weather when one provider request fails", async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
current: {
wave_height: 1.1,
wave_direction: 290,
wave_period: 5.3
}
})
)
.mockRejectedValueOnce(new Error("timeout"));
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
const response = await app.inject({
method: "GET",
url: "/api/weather/marine?lat=53.5&lon=7.1"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
waveHeightM: 1.1,
windSpeed: null,
source: "Open-Meteo Marine"
});
await app.close();
});
it("serves injected live navigation adapters and forwards bounded route filters", async () => {
const lock: LockOperationInfo = {
id: "lock-1",
name: "Schleuse Hamm",
waterway: "DHK",
regularHours: "06:00-22:00",
operatingState: "restricted",
validFrom: "2026-07-20T04:00:00.000Z",
validTo: "2026-07-20T20:00:00.000Z",
phone: "+49 2381 1234",
vhf: "Kanal 20",
note: "Anmeldung erforderlich",
updatedAt: "2026-07-19T12:00:00.000Z",
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/"
};
const load = vi.fn(async () => [lock]);
const lockAdapter: NavigationDataAdapter<LockOperationInfo> = {
kind: "lock-operations",
id: "test-elwis-locks",
label: "Test ELWIS locks",
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
load
};
const app = await buildServer({
cache: createCache(),
navigationAdapters: {
waterLevels: null,
lockOperations: lockAdapter,
notices: null
}
});
const response = await app.inject({
method: "GET",
url: "/api/navigation/live?waterways=EMS,DHK,EMS&lockIds=lock-1,lock-2"
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(load).toHaveBeenCalledWith(
{ waterways: ["EMS", "DHK"], lockIds: ["lock-1", "lock-2"] },
expect.objectContaining({ signal: expect.any(AbortSignal), fetcher: expect.any(Function) })
);
expect(body.lockOperations).toEqual([lock]);
expect(body.sources).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "lock-operations", id: "test-elwis-locks", state: "live" }),
expect.objectContaining({ kind: "water-levels", state: "not-configured" }),
expect.objectContaining({ kind: "notices", state: "not-configured" })
])
);
expect(body.generatedAt).toEqual(expect.any(String));
await app.close();
});
it("returns critical route warnings for shallow samples", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 53.563776, lon: 6.750562 },
vesselProfile: { draughtM: 1.5, safetyReserveM: 0.4 },
depthSamples: [{ coordinate: { lat: 53.442996, lon: 6.833146 }, depthM: 1.6 }]
}
});
expect(response.statusCode).toBe(200);
expect(response.json().warnings.some((warning: { severity: string }) => warning.severity === "critical")).toBe(
true
);
await app.close();
});
it("rejects routes without a known fairway instead of returning a straight line", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54.1749, lon: 12.0731 },
destination: { lat: 54.1833, lon: 12.0928 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(422);
expect(body.error).toBe("no_fairway_route");
expect(body.message).toContain("Keine Fahrwasserroute");
await app.close();
});
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 53.563776, lon: 6.750562 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
expect(body.geometry.coordinates.length).toBeGreaterThan(20);
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
await app.close();
});
it("returns the inland fallback route from Emden to Hamm", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.distanceNm).toBeGreaterThan(145);
expect(body.distanceNm).toBeLessThan(165);
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
await app.close();
});
it("uses an extracted fairway graph before the seed graph", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "test-extracted",
name: "Test Extracted Fairways",
maxSnapDistanceNm: 1,
nodes: [
{ id: "a", coordinate: { lat: 54, lon: 10 } },
{ id: "b", coordinate: { lat: 54.02, lon: 10.05 } },
{ id: "c", coordinate: { lat: 54.04, lon: 10.1 } }
],
edges: [
{
id: "ab",
name: "AB",
from: "a",
to: "b",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54, lon: 10 },
{ lat: 54.02, lon: 10.05 }
]
},
{
id: "bc",
name: "BC",
from: "b",
to: "c",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54.02, lon: 10.05 },
{ lat: 54.04, lon: 10.1 }
]
}
]
}
];
}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54, lon: 10 },
destination: { lat: 54.04, lon: 10.1 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:test-extracted");
expect(body.dataSources).toContain("test-overpass");
await app.close();
});
it("returns distinct route alternatives when the waterway graph contains them", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon });
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
id,
name: id,
from,
to,
coordinates,
minDepthM: 4,
source: "test-alternatives"
});
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "api-alternatives",
name: "API Alternativen",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "start", coordinate: coordinate(52, 7) },
{ id: "branch-in", coordinate: coordinate(52, 7.01) },
{ id: "upper", coordinate: coordinate(52.012, 7.03) },
{ id: "lower", coordinate: coordinate(51.988, 7.03) },
{ id: "branch-out", coordinate: coordinate(52, 7.05) },
{ id: "destination", coordinate: coordinate(52, 7.06) }
],
edges: [
edge("start-access", "start", "branch-in", [coordinate(52, 7), coordinate(52, 7.01)]),
edge("main", "branch-in", "branch-out", [coordinate(52, 7.01), coordinate(52, 7.05)]),
edge("upper-in", "branch-in", "upper", [coordinate(52, 7.01), coordinate(52.012, 7.03)]),
edge("upper-out", "upper", "branch-out", [coordinate(52.012, 7.03), coordinate(52, 7.05)]),
edge("lower-in", "branch-in", "lower", [coordinate(52, 7.01), coordinate(51.988, 7.03)]),
edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]),
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
]
}
];
}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: coordinate(52, 7),
destination: coordinate(52, 7.06),
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.name).toBe("Hauptroute");
expect(body.alternatives).toHaveLength(2);
expect(body.alternatives.map((route: { name: string }) => route.name)).toEqual(["Alternative 1", "Alternative 2"]);
await app.close();
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from "vitest";
import {
MAX_DETAIL_LIMIT,
buildLockRecord,
collectEurisLocks,
compactLocksFilter,
normalizePhones,
parseCountries,
parseDetailLimit,
requestJson,
risIndexFilter,
runEurisSync,
} from "../../../scripts/sync-euris-locks.mjs";
function jsonResponse(payload, init = {}) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function paginatedFixtureFetch({ recordRequests = [] } = {}) {
const compact = [
{
locode: "DELOCK001",
objectName: "Testschleuse Nord",
waterwayName: "Testkanal",
contactPhone: "0049 201 12345",
comcha: " 18 ",
},
{ locode: "DELOCK002", objectName: "Testschleuse Süd" },
];
const ris = [
{
isrs: "DELOCK001",
objectName: "Testschleuse Nord",
countryCode: "DE",
lon: 7.1,
lat: 51.5,
source: "WSV, Wadaba",
},
{
isrs: "DELOCK002",
objectName: "Testschleuse Süd",
countryCode: "DE",
lon: 7.2,
lat: 51.6,
source: "WSV, Wadaba",
},
{
isrs: "DELOCK003",
objectName: "Nur im RIS-Index",
countryCode: "DE",
lon: 7.3,
lat: 51.7,
source: "WSV",
},
];
return async (input, init) => {
const url = new URL(String(input));
recordRequests.push({ url, init });
const skip = Number(url.searchParams.get("$skip"));
const top = Number(url.searchParams.get("$top"));
if (url.pathname.endsWith("/GetCompactLocks")) {
return jsonResponse({ count: compact.length, items: compact.slice(skip, skip + top) });
}
if (url.pathname.endsWith("/GetRISIndexObjects")) {
return jsonResponse({ count: ris.length, items: ris.slice(skip, skip + top) });
}
throw new Error(`Unerwartete Test-URL: ${url}`);
};
}
describe("EuRIS lock synchronization", () => {
it("validates country filters and caps optional detail requests", () => {
expect(parseCountries(" de,NL de ")).toEqual(["DE", "NL"]);
expect(() => parseCountries("DEU")).toThrow(/ISO-Ländercode/u);
expect(parseDetailLimit("999")).toBe(MAX_DETAIL_LIMIT);
expect(compactLocksFilter(["DE", "NL"])).toContain("startswith(locode,'DE')");
expect(risIndexFilter(["DE", "NL"])).toBe(
"(countryCode eq 'DE' or countryCode eq 'NL') and function eq 'lokare'",
);
});
it("normalizes EuRIS contact data and always uses the RIS coordinate", () => {
expect(normalizePhones("0049 201 12345; +49 201 67890")).toEqual([
"+49 201 12345",
"+49 201 67890",
]);
const record = buildLockRecord({
compact: {
locode: "DELOCK001",
objectName: "Lock",
waterwayName: "Canal",
contactPhone: "0049 201 12345",
comcha: " 18 ",
},
ris: {
isrs: "DELOCK001",
lon: "7.123",
lat: "51.456",
source: "WSV, Wadaba",
countryCode: "DE",
},
detail: {
facility: {
street: "Uferstraße 1",
postCode: "12345",
city: "Teststadt",
country: "DE",
contacts: [
{
company: "Wasserstraßenverwaltung",
emails: ["lock@example.test"],
phones: ["+49 201 999"],
},
],
},
},
fetchedAt: "2026-07-20T10:00:00.000Z",
});
expect(record).toMatchObject({
sourceId: "DELOCK001",
longitude: 7.123,
latitude: 51.456,
properties: {
phone: "+49 201 12345",
vhf: "18",
waterway_name: "Canal",
operator: "Wasserstraßenverwaltung",
email: "lock@example.test",
address: "Uferstraße 1, 12345, Teststadt, DE",
upstream_source: "WSV, Wadaba",
fetched_at: "2026-07-20T10:00:00.000Z",
},
});
expect(record.properties.source_url).toContain("isrs=DELOCK001");
});
it("honors Retry-After for throttled requests and sends an optional bearer token", async () => {
const waits = [];
const headers = [];
let calls = 0;
const fetchImpl = async (_url, init) => {
calls += 1;
headers.push(new Headers(init.headers));
if (calls === 1) {
return new Response("rate limited", {
status: 429,
headers: { "retry-after": "2" },
});
}
return jsonResponse({ ok: true });
};
await expect(
requestJson("https://example.test/euris", {
fetchImpl,
token: "secret-token",
sleepImpl: async (milliseconds) => waits.push(milliseconds),
}),
).resolves.toEqual({ ok: true });
expect(waits).toEqual([2_000]);
expect(headers.every((entry) => entry.get("authorization") === "Bearer secret-token")).toBe(true);
});
it("paginates compact and RIS data stably, joins by ISRS and keeps RIS-only locks", async () => {
const requests = [];
const result = await collectEurisLocks({
countries: ["DE"],
detailLimit: 0,
pageSize: 1,
fetchImpl: paginatedFixtureFetch({ recordRequests: requests }),
token: "token",
fetchedAt: "2026-07-20T10:00:00.000Z",
});
expect(result.records.map((record) => record.sourceId)).toEqual([
"DELOCK001",
"DELOCK002",
"DELOCK003",
]);
expect(result.stats).toMatchObject({
compactLocks: 2,
risLocks: 3,
joinedLocks: 2,
risOnlyLocks: 1,
storedLocks: 3,
});
expect(requests.every(({ url }) => Number(url.searchParams.get("$top")) <= 100)).toBe(true);
expect(requests.every(({ url }) => url.searchParams.has("$orderby"))).toBe(true);
expect(
requests.every(({ init }) => new Headers(init.headers).get("authorization") === "Bearer token"),
).toBe(true);
});
it("does not invoke the database writer in dry-run mode", async () => {
const writer = vi.fn();
const result = await runEurisSync({
dryRun: true,
writer,
collectorOptions: {
countries: ["DE"],
pageSize: 100,
fetchImpl: paginatedFixtureFetch(),
},
});
expect(result.records).toHaveLength(3);
expect(writer).not.toHaveBeenCalled();
});
});
+265
View File
@@ -0,0 +1,265 @@
import { describe, expect, it } from "vitest";
import { buildRoute } from "@watermaps/shared";
import {
fairwayRowsToGraph,
mergeConnectedFairwayGraphs,
overpassToGraph
} from "../src/services/fairways.js";
describe("fairway graph extraction", () => {
it("builds a routable graph from PostGIS fairway rows", () => {
const graph = fairwayRowsToGraph(
[
{
id: "1",
source: "osm",
source_id: "way-1",
name: "Harbour Reach",
min_depth_m: "4.2",
geometry: {
type: "LineString",
coordinates: [
[10, 54],
[10.04, 54.02]
]
}
},
{
id: "2",
source: "osm",
source_id: "way-2",
name: "Outer Reach",
min_depth_m: 4.2,
geometry: {
type: "LineString",
coordinates: [
[10.04, 54.02],
[10.1, 54.04]
]
}
}
],
[9.9, 53.9, 10.2, 54.1]
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start: { lat: 54, lon: 10 },
destination: { lat: 54.04, lon: 10.1 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.4 }
},
graph ?? undefined
);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("fairway-graph:postgis-9.900-53.900-10.200-54.100");
expect(route?.dataSources).toContain("postgis-osm");
});
it("routes the iPhone Emden coordinates through intermediate fairway vertices", () => {
const start = { lat: 53.3306, lon: 7.1752 };
const destination = { lat: 53.6741, lon: 7.1474 };
const graph = fairwayRowsToGraph(
[
{
id: "emden-main-reach",
source: "osm",
source_id: "way-main",
name: "Ems Fahrwasser",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.1751368, 53.3331995],
[7.16, 53.42],
[7.1474, 53.55],
[7.18, 53.61]
]
}
},
{
id: "busetief-branch",
source: "osm",
source_id: "way-branch",
name: "Busetief",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.1474, 53.55],
[7.1414995, 53.6668156]
]
}
}
],
[6.9974, 53.1806, 7.3252, 53.8241]
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start,
destination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
},
graph ?? undefined
);
expect(route).not.toBeNull();
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("postgis-osm");
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
});
it("builds a graph from OSM/OpenSeaMap Overpass ways", () => {
const graph = overpassToGraph(
{
elements: [
{
type: "way",
id: 123,
tags: {
"seamark:type": "navigation_line",
"seamark:navigation_line:minimum_depth": "3.5"
},
geometry: [
{ lat: 54, lon: 10 },
{ lat: 54.02, lon: 10.05 }
]
}
]
},
[9.9, 53.9, 10.1, 54.1]
);
expect(graph).not.toBeNull();
expect(graph?.edges[0]?.source).toBe("osm-overpass-seamarks");
expect(graph?.edges[0]?.minDepthM).toBe(3.5);
});
it("accepts navigable canals but rejects explicitly closed waterways", () => {
const graph = overpassToGraph(
{
elements: [
{
type: "way",
id: 201,
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
geometry: [
{ lat: 51.65, lon: 7.35 },
{ lat: 51.66, lon: 7.4 }
]
},
{
type: "way",
id: 202,
tags: { waterway: "canal", boat: "no", name: "Gesperrter Kanal" },
geometry: [
{ lat: 51.66, lon: 7.4 },
{ lat: 51.67, lon: 7.45 }
]
}
]
},
[7.3, 51.6, 7.5, 51.7]
);
expect(graph).not.toBeNull();
expect(graph?.edges).toHaveLength(1);
expect(graph?.edges[0]?.name).toBe("Datteln-Hamm-Kanal");
});
it("topologically joins graph fragments from PostGIS and live data", () => {
const first = fairwayRowsToGraph(
[
{
id: "north",
source: "osm",
source_id: "north",
name: "Dortmund-Ems-Kanal",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.3, 52.1],
[7.35, 52]
]
}
}
],
[7.2, 51.8, 7.6, 52.2]
);
const second = overpassToGraph(
{
elements: [
{
type: "way",
id: 301,
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
geometry: [
{ lat: 52, lon: 7.35 },
{ lat: 51.9, lon: 7.5 }
]
}
]
},
[7.2, 51.8, 7.6, 52.2]
);
const graph = mergeConnectedFairwayGraphs([first!, second!]);
const route = buildRoute(
{
start: { lat: 52.1, lon: 7.3 },
destination: { lat: 51.9, lon: 7.5 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 }
},
graph ?? undefined
);
expect(route).not.toBeNull();
expect(route?.dataSources).toContain("postgis-osm");
expect(route?.dataSources).toContain("osm-overpass-waterway-canal");
});
it("carries OSM vessel restrictions into the routing graph", () => {
const graph = fairwayRowsToGraph(
[
{
id: "restricted",
source: "osm",
source_id: "way-restricted",
name: "Niedrige Durchfahrt",
min_depth_m: "3.0",
properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" },
geometry: {
type: "LineString",
coordinates: [
[7, 52],
[7.04, 52]
]
}
}
],
[6.9, 51.9, 7.1, 52.1]
);
expect(graph?.edges[0]).toMatchObject({
maxAirDraftM: 2.4,
maxBeamM: 3.2,
maxDraughtM: 1.8,
oneway: true
});
expect(
buildRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.3, airDraftM: 2.5, beamM: 3 }
},
graph ?? undefined
)
).toBeNull();
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
deduplicateMarineContactFeatures,
normalizeDepthFeatureProperties,
normalizeMarineFeatureProperties
} from "../src/services/features.js";
describe("marine feature normalization", () => {
it("formats bridge clearance labels from known OSM height tags", () => {
const properties = normalizeMarineFeatureProperties({
layer: "bridges",
name: "Kaiser-Wilhelm-Brücke",
source: "osm",
sourceId: "w123",
properties: {
bridge: "movable",
maxheight: "3"
}
});
expect(properties.clearance_m).toBe(3);
expect(properties.clearance_label).toBe("H 3 m");
expect(properties.label).toBe("Kaiser-Wilhelm-Brücke H 3 m");
});
it("ignores non-numeric default bridge heights", () => {
const properties = normalizeMarineFeatureProperties({
layer: "bridges",
name: null,
source: "osm",
sourceId: "w124",
properties: {
bridge: "yes",
maxheight: "default"
}
});
expect(properties.clearance_m).toBeNull();
expect(properties.label).toBeNull();
});
it("normalizes contact aliases, address and database timestamps", () => {
const properties = normalizeMarineFeatureProperties({
layer: "locks",
name: "Schleuse Hamm",
source: "osm",
sourceId: "w166568834",
updatedAt: new Date("2026-07-19T08:30:00.000Z"),
properties: {
"contact:phone": "+49 2381 9019280",
"contact:website": "https://example.test/schleuse-hamm",
"contact:email": "schleuse@example.test",
"seamark:lock_basin:communication_channel": "18",
opening_hours: "24/7",
operator: "WSV",
"addr:street": "Fährstraße",
"addr:housenumber": "1",
"addr:postcode": "59071",
"addr:city": "Hamm",
"addr:country": "DE"
}
});
expect(properties.phone).toBe("+49 2381 9019280");
expect(properties.website).toBe("https://example.test/schleuse-hamm");
expect(properties.email).toBe("schleuse@example.test");
expect(properties.vhf).toBe("18");
expect(properties.openingHours).toBe("24/7");
expect(properties.operator).toBe("WSV");
expect(properties.address).toBe("Fährstraße 1, 59071 Hamm, DE");
expect(properties.source).toBe("osm");
expect(properties.sourceId).toBe("w166568834");
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
});
it("prefers direct contact fields and returns stable null values when details are absent", () => {
const properties = normalizeMarineFeatureProperties({
layer: "harbours",
name: "Marina Emden",
source: "osm",
sourceId: "n1",
properties: {
phone: "+49 4921 123",
"contact:phone": "+49 4921 999"
}
});
expect(properties.phone).toBe("+49 4921 123");
expect(properties.website).toBeNull();
expect(properties.email).toBeNull();
expect(properties.vhf).toBeNull();
expect(properties.openingHours).toBeNull();
expect(properties.operator).toBeNull();
expect(properties.address).toBeNull();
expect(properties.updatedAt).toBeNull();
});
it("keeps navigation details but removes unrelated bulk OSM tags from viewport features", () => {
const properties = normalizeMarineFeatureProperties({
layer: "harbours",
name: "Testhafen",
source: "osm",
sourceId: "w42",
properties: {
leisure: "marina",
electricity: "yes",
"contact:phone": "+49 40 123",
"source:geometry": "survey",
note: "A very large unrelated note that is not consumed by the client"
}
});
expect(properties.leisure).toBe("marina");
expect(properties.electricity).toBe("yes");
expect(properties.phone).toBe("+49 40 123");
expect(properties).not.toHaveProperty("source:geometry");
expect(properties).not.toHaveProperty("note");
});
it("formats fairway depth labels", () => {
const properties = normalizeDepthFeatureProperties({
name: "Nord-Ostsee-Kanal",
source: "osm",
sourceId: "w456",
minDepthM: "14",
properties: {
depth: "14"
}
});
expect(properties.depth_m).toBe(14);
expect(properties.depth_label).toBe("14 m");
expect(properties.label).toBe("Nord-Ostsee-Kanal 14 m");
});
});
describe("marine contact feature deduplication", () => {
it("returns one stable facility feature and reports how many raw objects were merged", () => {
const result = deduplicateMarineContactFeatures([
{
type: "Feature",
id: "12",
geometry: { type: "Point", coordinates: [7.867, 51.695] },
properties: {
layer: "locks",
source: "osm",
sourceId: "w12",
name: "Schleuse Werries",
website: "https://example.test/werries"
}
},
{
type: "Feature",
id: "99",
geometry: { type: "Point", coordinates: [7.86708, 51.69508] },
properties: {
layer: "locks",
source: "euris",
sourceId: "DEHMM00301LOCKS00404",
name: "Werries",
phone: "+49 2381 9019-290",
vhf: "22",
"ref:EU:RIS": "DEHMM00301LOCKS00404"
}
},
{
type: "Feature",
id: "bridge-1",
geometry: { type: "LineString", coordinates: [[7.8, 51.6], [7.9, 51.7]] },
properties: { layer: "bridges", source: "osm", name: "Testbrücke" }
}
]);
expect(result.metadata).toEqual({ inputPoiCount: 2, outputPoiCount: 1, mergedObjectCount: 1 });
expect(result.features).toHaveLength(2);
expect(result.features.find((feature) => feature.properties.layer === "locks")).toMatchObject({
id: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
properties: {
name: "Werries",
phone: "+49 2381 9019-290",
website: "https://example.test/werries",
dedupeMemberCount: 2
}
});
});
});
@@ -0,0 +1,467 @@
import { describe, expect, it, vi } from "vitest";
import {
buildFacilitySearchQuery,
buildSearchEnrichmentRecord,
enrichSearchCandidates,
evaluateFacilityPageMatch,
parseDuckDuckGoResults,
runSearchEnrichment,
searchBrave,
searchDuckDuckGo,
scoreSearchResult,
unwrapDuckDuckGoUrl,
} from "../../../scripts/enrich-marine-search.mjs";
const MATCH_THRESHOLD = 70;
const FETCHED_AT = "2026-07-23T09:30:00.000Z";
function searchCandidate(overrides = {}) {
return {
id: "42",
layer: "locks",
source: "osm",
sourceId: "w123",
name: "Schleuse Werries",
properties: {
"addr:city": "Hamm",
"addr:country": "DE",
waterway_name: "Datteln-Hamm-Kanal",
},
enrichmentProperties: {},
...overrides,
};
}
const matchingResult = {
url: "https://www.wsa.example/schleuse-werries",
title: "Schleuse Werries | WSA Westdeutsche Kanäle",
snippet: "Offizielle Informationen und Kontakt zur Schleuse Werries in Hamm am Datteln-Hamm-Kanal.",
};
const matchingPageHtml = `
<!doctype html>
<html lang="de">
<head>
<title>Schleuse Werries | WSA Westdeutsche Kanäle</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "GovernmentOrganization",
"name": "WSA Westdeutsche Kanäle",
"address": {
"@type": "PostalAddress",
"addressLocality": "Hamm",
"addressCountry": "DE"
}
}
</script>
</head>
<body>
<main>
<h1>Schleuse Werries</h1>
<p>Datteln-Hamm-Kanal in Hamm</p>
</main>
</body>
</html>
`;
const identityOnlyPageHtml = `
<!doctype html>
<html lang="de">
<head><title>Schleuse Werries | WSA Westdeutsche Kanäle</title></head>
<body>
<main>
<h1>Schleuse Werries</h1>
<p>Datteln-Hamm-Kanal in Hamm</p>
</main>
</body>
</html>
`;
describe("marine facility search discovery", () => {
it("builds a stable, specific query and refuses generic facility names", () => {
const query = buildFacilitySearchQuery(searchCandidate());
expect(query).toContain('"Schleuse Werries"');
expect(query).toContain("Hamm");
expect(query).toContain("Datteln-Hamm-Kanal");
expect(query).toMatch(/Kontakt/iu);
expect(query).not.toMatch(/\b(?:undefined|null)\b/iu);
expect(
buildFacilitySearchQuery(
searchCandidate({
name: "Hafen",
layer: "harbours",
properties: { "addr:city": "Hamm" },
}),
),
).toBeNull();
expect(buildFacilitySearchQuery(searchCandidate({ name: "Schleuse", properties: {} }))).toBeNull();
expect(buildFacilitySearchQuery(searchCandidate({ name: null, properties: {} }))).toBeNull();
});
it("unwraps DuckDuckGo targets but rejects internal and unsafe links", () => {
const target = "https://www.wsa.example/schleuse-werries?view=contact";
const wrapped =
`//duckduckgo.com/l/?uddg=${encodeURIComponent(target)}` +
"&amp;rut=0123456789";
expect(unwrapDuckDuckGoUrl(wrapped)).toBe(target);
expect(unwrapDuckDuckGoUrl(target)).toBe(target);
expect(unwrapDuckDuckGoUrl("/html/?q=schleuse+werries")).toBeNull();
expect(unwrapDuckDuckGoUrl("javascript:alert(1)")).toBeNull();
expect(unwrapDuckDuckGoUrl("mailto:test@example.test")).toBeNull();
});
it("parses organic DuckDuckGo results, decodes text and removes ads and duplicates", () => {
const wrappedTarget =
"//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.wsa.example%2Fschleuse-werries%23kontakt" +
"&amp;rut=abc";
const html = `
<div class="result results_links results_links_deep web-result">
<h2 class="result__title">
<a rel="nofollow" class="result__a" href="${wrappedTarget}">
Schleuse <b>Werries</b> &amp; Kontakt
</a>
</h2>
<a class="result__snippet">
Offizielle Informationen f&uuml;r Hamm. Telefon &amp; E-Mail.
</a>
</div>
<div class="result result--ad">
<h2>
<a class="result__a" href="https://advertising.example/werries">
Anzeige f&uuml;r Werries
</a>
</h2>
<span class="result__badge">Ad</span>
</div>
<div class="result">
<a class="result__a" href="https://www.wsa.example/schleuse-werries#anfahrt">
Derselbe Treffer ein zweites Mal
</a>
<a class="result__snippet">Duplikat</a>
</div>
<div class="result">
<a class="result__a" href="https://hafen.example/kontakt?lang=de">
Hafenservice Hamm
</a>
<a class="result__snippet">Ein zweiter organischer Treffer.</a>
</div>
<a href="javascript:alert(1)" class="result__a">Unsicher</a>
`;
expect(parseDuckDuckGoResults(html, { limit: 10 })).toEqual([
{
url: "https://www.wsa.example/schleuse-werries",
title: "Schleuse Werries & Kontakt",
snippet: "Offizielle Informationen für Hamm. Telefon & E-Mail.",
rank: 1,
},
{
url: "https://hafen.example/kontakt?lang=de",
title: "Hafenservice Hamm",
snippet: "Ein zweiter organischer Treffer.",
rank: 2,
},
]);
expect(parseDuckDuckGoResults(html, { limit: 1 })).toHaveLength(1);
});
it("treats a DuckDuckGo browser challenge as a provider block, not as no results", async () => {
await expect(
searchDuckDuckGo("Schleuse Werries Hamm", {
fetchPageImpl: async () => ({
status: 202,
finalUrl: "https://html.duckduckgo.com/html/",
html: '<form id="challenge-form"><script src="/anomaly.js"></script></form>',
}),
}),
).rejects.toMatchObject({ code: "PROVIDER_BLOCKED", status: 202 });
});
it("supports the authenticated Brave API through the same normalized result shape", async () => {
const fetchImpl = vi.fn(async (_url, init) => {
expect(init.headers["X-Subscription-Token"]).toBe("test-token");
return new Response(
JSON.stringify({
web: {
results: [
{
title: "Schleuse Werries",
url: "https://www.wsa.example/schleuse-werries",
description: "Kontakt in Hamm",
},
],
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
await expect(
searchBrave("Schleuse Werries Hamm", {
apiKey: "test-token",
fetchImpl,
limit: 3,
}),
).resolves.toEqual([
{
title: "Schleuse Werries",
url: "https://www.wsa.example/schleuse-werries",
snippet: "Kontakt in Hamm",
rank: 1,
},
]);
});
it("requires a distinctive name plus matching place evidence before accepting a result", () => {
const accepted = scoreSearchResult(searchCandidate(), matchingResult);
const wrongPlace = scoreSearchResult(searchCandidate(), {
...matchingResult,
url: "https://tourismus.example/amsterdam/werries",
title: "Schleuse Werries in Amsterdam",
snippet: "Besuchen Sie die historische Schleuse in Amsterdam, Noord-Holland.",
});
const missingName = scoreSearchResult(searchCandidate(), {
url: "https://www.hamm.example/schleusen",
title: "Wasserstraßen und Schleusen in Hamm",
snippet: "Kontakt für den Datteln-Hamm-Kanal.",
});
const genericName = scoreSearchResult(
searchCandidate({ name: "Schleuse", properties: { "addr:city": "Hamm" } }),
matchingResult,
);
expect(accepted.accepted).toBe(true);
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
expect(accepted.evidence.length).toBeGreaterThan(0);
expect(wrongPlace.accepted).toBe(false);
expect(wrongPlace.score).toBeLessThan(MATCH_THRESHOLD);
expect(missingName.accepted).toBe(false);
expect(genericName).toMatchObject({ accepted: false, score: 0 });
});
it("validates the fetched page itself and rejects a misleading redirect or generic homepage", () => {
const accepted = evaluateFacilityPageMatch(searchCandidate(), {
html: matchingPageHtml,
finalUrl: "https://www.wsa.example/schleuse-werries",
});
const genericHomepage = evaluateFacilityPageMatch(searchCandidate(), {
html: `
<html>
<head><title>WSA Westdeutsche Kanäle</title></head>
<body><h1>Willkommen</h1><p>Allgemeine Informationen zur Wasserstraßenverwaltung.</p></body>
</html>
`,
finalUrl: "https://www.wsa.example/",
});
const wrongFacility = evaluateFacilityPageMatch(searchCandidate(), {
html: `
<html>
<head><title>Schleuse Werries Amsterdam</title></head>
<body><h1>Schleuse Werries</h1><address>Amsterdam, NL</address></body>
</html>
`,
finalUrl: "https://tourismus.example/amsterdam/werries",
});
expect(accepted.accepted).toBe(true);
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
expect(accepted.evidence.length).toBeGreaterThan(0);
expect(genericHomepage.accepted).toBe(false);
expect(wrongFacility.accepted).toBe(false);
});
it("stores the verified website with auditable provenance and never overwrites existing contacts", () => {
const candidate = searchCandidate({
properties: {
"addr:city": "Hamm",
phone: "+49 2381 100",
},
enrichmentProperties: {
operator: "Vorhandener Betreiber",
},
});
const query = buildFacilitySearchQuery(candidate);
const page = {
html: matchingPageHtml,
finalUrl: "https://www.wsa.example/anlagen/schleuse-werries",
};
const match = evaluateFacilityPageMatch(candidate, page);
const record = buildSearchEnrichmentRecord({
candidate,
query,
providerId: "duckduckgo",
searchResult: matchingResult,
page,
match,
extracted: {
phone: "+49 2381 999",
email: "schleuse-werries@example.test",
operator: "Anderer Betreiber",
address: null,
},
fetchedAt: FETCHED_AT,
});
expect(record).toMatchObject({
originalId: "42",
sourceId: "osm:w123",
properties: {
website: "https://www.wsa.example/anlagen/schleuse-werries",
phone: "+49 2381 100",
email: "schleuse-werries@example.test",
operator: "Vorhandener Betreiber",
original_source: "osm",
original_source_id: "w123",
enrichmentSource: "facility-search",
enrichmentProvider: "duckduckgo",
searchQuery: query,
searchResultUrl: matchingResult.url,
source_url: page.finalUrl,
fetched_at: FETCHED_AT,
},
});
expect(record.properties.searchScore).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
expect(record.properties.enriched_fields).toEqual(
expect.arrayContaining(["website", "email"]),
);
expect(record.properties.enriched_fields).not.toContain("phone");
expect(record.properties.enriched_fields).not.toContain("operator");
});
it("persists a verified discovered website even when the page exposes no contact fields", async () => {
const search = vi.fn(async () => [matchingResult]);
const fetchPageImpl = vi.fn(async () => ({
html: identityOnlyPageHtml,
finalUrl: matchingResult.url,
redirects: 0,
}));
const result = await enrichSearchCandidates([searchCandidate()], {
searchProvider: { id: "duckduckgo", search },
fetchPageImpl,
concurrency: 1,
hostDelayMs: 0,
maxResults: 5,
maxPages: 2,
fetchedAt: FETCHED_AT,
logger: { warn: vi.fn() },
});
expect(search).toHaveBeenCalledTimes(1);
expect(search.mock.calls[0][0]).toContain("Schleuse Werries");
expect(fetchPageImpl).toHaveBeenCalledTimes(1);
expect(result.records).toHaveLength(1);
expect(result.records[0]).toMatchObject({
originalId: "42",
sourceId: "osm:w123",
properties: {
website: matchingResult.url,
enrichmentProvider: "duckduckgo",
fetched_at: FETCHED_AT,
},
});
expect(result.records[0].properties.enriched_fields).toContain("website");
});
it("opens the provider circuit after a block response and does not continue querying", async () => {
const blockedError = Object.assign(new Error("DuckDuckGo hat weitere Anfragen blockiert."), {
code: "SEARCH_PROVIDER_BLOCKED",
status: 429,
});
const search = vi.fn(async () => {
throw blockedError;
});
const fetchPageImpl = vi.fn();
const logger = { warn: vi.fn() };
const result = await enrichSearchCandidates(
[
searchCandidate(),
searchCandidate({
id: "43",
sourceId: "w124",
name: "Schleuse Uentrop",
properties: { "addr:city": "Hamm" },
}),
],
{
searchProvider: { id: "duckduckgo", search },
fetchPageImpl,
concurrency: 1,
hostDelayMs: 0,
fetchedAt: FETCHED_AT,
logger,
},
);
expect(result.records).toEqual([]);
expect(search).toHaveBeenCalledTimes(1);
expect(fetchPageImpl).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
});
it("keeps the database untouched in the default dry-run", async () => {
const writer = vi.fn();
const search = vi.fn(async () => [matchingResult]);
const fetchPageImpl = vi.fn(async () => ({
html: identityOnlyPageHtml,
finalUrl: matchingResult.url,
redirects: 0,
}));
const result = await runSearchEnrichment({
candidates: [searchCandidate()],
writer,
enrichmentOptions: {
searchProvider: { id: "duckduckgo", search },
fetchPageImpl,
concurrency: 1,
hostDelayMs: 0,
fetchedAt: FETCHED_AT,
},
});
expect(result.dryRun).toBe(true);
expect(result.records).toHaveLength(1);
expect(writer).not.toHaveBeenCalled();
});
it("passes validated records and checkpoint attempts to the writer only when enabled", async () => {
const writer = vi.fn(async () => ({ enrichments: 1, attempts: 1 }));
const result = await runSearchEnrichment({
dryRun: false,
candidates: [searchCandidate()],
writer,
enrichmentOptions: {
searchProvider: { id: "duckduckgo", search: async () => [matchingResult] },
fetchPageImpl: async () => ({
html: identityOnlyPageHtml,
finalUrl: matchingResult.url,
redirects: 0,
}),
concurrency: 1,
hostDelayMs: 0,
fetchedAt: FETCHED_AT,
},
});
expect(result.records).toHaveLength(1);
expect(result.attempts).toHaveLength(1);
expect(result.attempts[0]).toMatchObject({
status: "success",
provider: "duckduckgo",
originalSource: "osm",
originalSourceId: "w123",
});
expect(writer).toHaveBeenCalledTimes(1);
expect(writer.mock.calls[0][0]).toMatchObject({
records: [expect.objectContaining({ sourceId: "osm:w123" })],
attempts: [expect.objectContaining({ status: "success" })],
});
});
});
@@ -0,0 +1,290 @@
import { describe, expect, it, vi } from "vitest";
import {
DEFAULT_LIMIT,
MAX_HTML_BYTES,
assertPublicHttpUrl,
buildEnrichmentRecord,
createHostLimiter,
createPinnedLookup,
extractContactsFromHtml,
fetchHtmlPage,
parseBooleanDefault,
runWebsiteEnrichment,
} from "../../../scripts/enrich-marine-websites.mjs";
const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
function htmlResponse(html, init = {}) {
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
...init,
});
}
function candidate(overrides = {}) {
return {
id: "42",
layer: "harbours",
source: "osm",
sourceId: "w123",
name: "Testhafen",
properties: { website: "https://marina.example/contact" },
...overrides,
};
}
describe("marine facility website enrichment", () => {
it("defaults to dry-run and validates explicit boolean values", () => {
expect(DEFAULT_LIMIT).toBe(25);
expect(parseBooleanDefault(undefined)).toBe(true);
expect(parseBooleanDefault("false")).toBe(false);
expect(() => parseBooleanDefault("maybe")).toThrow(/true oder false/u);
});
it.each([
"http://127.0.0.1/admin",
"http://[::1]/admin",
"http://localhost/admin",
"http://service.local/admin",
])("blocks local URL %s before fetching", async (url) => {
await expect(assertPublicHttpUrl(url, { lookupImpl: publicDns })).rejects.toMatchObject({
name: "WebsiteEnrichmentError",
});
});
it("rejects a public hostname if any DNS result is private", async () => {
const lookupImpl = vi.fn(async () => [
{ address: "93.184.216.34", family: 4 },
{ address: "10.0.0.8", family: 4 },
]);
await expect(
assertPublicHttpUrl("https://marina.example", { lookupImpl }),
).rejects.toMatchObject({ code: "SSRF_BLOCKED_DNS" });
});
it("pins the socket lookup to the already validated DNS addresses", async () => {
const lookup = createPinnedLookup([{ address: "93.184.216.34", family: 4 }]);
const addresses = await new Promise((resolve, reject) => {
lookup("a-second-dns-name.example", { all: true }, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
expect(addresses).toEqual([{ address: "93.184.216.34", family: 4 }]);
expect(() => createPinnedLookup([{ address: "127.0.0.1", family: 4 }])).toThrow(
/gepinnt/u,
);
});
it("checks a redirect target again and never requests a private redirect", async () => {
const fetchImpl = vi.fn(async () =>
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data" },
}),
);
await expect(
fetchHtmlPage("https://marina.example", {
fetchImpl,
lookupImpl: publicDns,
beforeRequest: async () => {},
}),
).rejects.toMatchObject({ code: "SSRF_BLOCKED_IP" });
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
});
it("requires HTML and stops streamed responses above 512 KiB", async () => {
await expect(
fetchHtmlPage("https://marina.example/file.pdf", {
lookupImpl: publicDns,
beforeRequest: async () => {},
fetchImpl: async () =>
new Response("pdf", { status: 200, headers: { "content-type": "application/pdf" } }),
}),
).rejects.toMatchObject({ code: "UNSUPPORTED_CONTENT_TYPE" });
await expect(
fetchHtmlPage("https://marina.example/huge", {
lookupImpl: publicDns,
beforeRequest: async () => {},
fetchImpl: async () => htmlResponse("x".repeat(MAX_HTML_BYTES + 1)),
}),
).rejects.toMatchObject({ code: "BODY_TOO_LARGE" });
});
it("aborts a hanging HTTP request at the configured timeout", async () => {
const fetchImpl = async (_url, init) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
});
await expect(
fetchHtmlPage("https://marina.example/hangs", {
fetchImpl,
lookupImpl: publicDns,
beforeRequest: async () => {},
timeoutMs: 5,
}),
).rejects.toMatchObject({ code: "FETCH_FAILED" });
});
it("also bounds a hanging DNS lookup", async () => {
const fetchImpl = vi.fn();
await expect(
fetchHtmlPage("https://marina.example/hangs", {
fetchImpl,
lookupImpl: async () => new Promise(() => {}),
beforeRequest: async () => {},
timeoutMs: 5,
}),
).rejects.toMatchObject({ code: "DNS_TIMEOUT" });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("prefers JSON-LD and otherwise accepts only tel/mailto links", () => {
const html = `
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Marina",
"name": "Hafenbetrieb Musterstadt",
"telephone": "+49 201 11111",
"email": "hafen@example.test",
"address": {
"@type": "PostalAddress",
"streetAddress": "Ufer 1",
"postalCode": "12345",
"addressLocality": "Musterstadt",
"addressCountry": "DE"
}
}
</script>
<a href="tel:+49-201-99999">Alternative</a>
<a href="mailto:other@example.test">Alternative</a>
<p>Telefon 01234 567890</p>
`;
expect(extractContactsFromHtml(html)).toEqual({
phone: "+49 201 11111",
email: "hafen@example.test",
operator: "Hafenbetrieb Musterstadt",
address: "Ufer 1, 12345 Musterstadt, DE",
});
expect(extractContactsFromHtml("<p>Telefon 01234 567890</p>")).toEqual({
phone: null,
email: null,
operator: null,
address: null,
});
expect(
extractContactsFromHtml(
'<a href="tel:0049%20201%20777">Anrufen</a><a href="mailto:lock%40example.test">Mail</a>',
),
).toMatchObject({ phone: "+49 201 777", email: "lock@example.test" });
expect(
extractContactsFromHtml('<a title="some href=\'tel:+49999\'">Kein Kontaktlink</a>'),
).toMatchObject({ phone: null });
expect(extractContactsFromHtml('<a href="tel:+49201&#999999999999;">X</a>')).toMatchObject({
phone: null,
});
expect(
extractContactsFromHtml(
'<script type="application/ld+json">{"@type":"Marina","name":"Hafen\\u0000","email":"bad@example.test\\u0000"}</script>',
),
).toMatchObject({ email: null, operator: null });
expect(
extractContactsFromHtml(
'<script type="application/ld+json">{"@type":"Organization","name":"Schleusenbetrieb Nord"}</script><a href="tel:+49-201-555">Telefon</a>',
),
).toMatchObject({ phone: "+49-201-555", operator: "Schleusenbetrieb Nord" });
});
it("keeps existing contact values and only fills missing fields", () => {
const record = buildEnrichmentRecord({
candidate: candidate({
properties: {
website: "https://marina.example/contact",
phone: "+49 201 100",
},
enrichmentProperties: { operator: "Vorhandener Hafenbetreiber" },
}),
website: {
key: "website",
original: "https://marina.example/contact",
url: "https://marina.example/contact",
},
page: { finalUrl: "https://marina.example/kontakt" },
extracted: {
phone: "+49 201 999",
email: "hafen@example.test",
},
fetchedAt: "2026-07-20T11:00:00.000Z",
});
expect(record).toMatchObject({
sourceId: "osm:w123",
properties: {
website: "https://marina.example/contact",
phone: "+49 201 100",
email: "hafen@example.test",
operator: "Vorhandener Hafenbetreiber",
source_url: "https://marina.example/kontakt",
fetchedAt: "2026-07-20T11:00:00.000Z",
enriched_fields: ["email"],
},
});
});
it("spaces starts to the same host while allowing a deterministic injected clock", async () => {
let currentTime = 1_000;
const waits = [];
const limiter = createHostLimiter({
delayMs: 500,
now: () => currentTime,
sleepImpl: async (milliseconds) => {
waits.push(milliseconds);
currentTime += milliseconds;
},
});
await limiter(new URL("https://marina.example/one"));
await limiter(new URL("https://marina.example/two"));
await limiter(new URL("https://other.example/one"));
expect(waits).toEqual([500]);
});
it("does not invoke the database writer during the default dry-run", async () => {
const writer = vi.fn();
const fetchImpl = vi.fn(async (_url, init) => {
expect(init.headers["User-Agent"]).toContain("Watermaps");
return htmlResponse('<a href="tel:+49-201-12345">Schleuse anrufen</a>');
});
const result = await runWebsiteEnrichment({
candidates: [
candidate(),
candidate({ id: "43", sourceId: "w124", properties: { website: "https://other.example" } }),
],
limit: 1,
writer,
enrichmentOptions: {
fetchImpl,
lookupImpl: publicDns,
hostDelayMs: 0,
fetchedAt: "2026-07-20T11:00:00.000Z",
},
});
expect(result.dryRun).toBe(true);
expect(result.records).toHaveLength(1);
expect(result.records[0]).toMatchObject({
originalId: "42",
sourceId: "osm:w123",
properties: { phone: "+49-201-12345" },
});
expect(writer).not.toHaveBeenCalled();
});
});
+260
View File
@@ -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"
};
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
featureName,
isLockFeature,
sourceId
} from "../../../scripts/osm-marine-classification.mjs";
describe("OSM marine import classification", () => {
it.each([
{ lock: "yes" },
{ waterway: "lock_gate" },
{ waterway: "lock" },
{ natural: "water", water: "lock" },
{ obstacle: "lock" },
{ "seamark:type": "lock_basin" },
{ "seamark:type": "gate", "seamark:gate:category": "lock" }
])("recognizes a lock encoded as %o", (properties) => {
expect(isLockFeature(properties)).toBe(true);
});
it("does not classify an unrelated gate as a lock", () => {
expect(isLockFeature({ "seamark:type": "gate", "seamark:gate:category": "flood_barrage" })).toBe(false);
});
it("uses the canonical OSM id for converted area features", () => {
expect(
sourceId(
{ id: "a69307610", properties: { "@type": "way", "@id": 34653805 } },
1,
"nordrhein-westfalen-latest"
)
).toBe("w34653805");
});
it("prefers the lock name and falls back to the seamark name", () => {
expect(featureName({ lock_name: "Schleuse Test" })).toBe("Schleuse Test");
expect(featureName({ name: "Testkanal", lock_name: "Schleuse Test" })).toBe("Schleuse Test");
expect(featureName({ "seamark:name": "Test Lock" })).toBe("Test Lock");
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import { createCache } from "../src/services/cache.js";
import type { FetchLike } from "../src/services/http.js";
import { getNearestTideSummary, normalizeNearestTideSummary } from "../src/services/tides.js";
describe("BSH tide normalization", () => {
it("selects the nearest station and upcoming events", () => {
const summary = normalizeNearestTideSummary(
{
features: [
{
geometry: { type: "Point", coordinates: [12.1, 54.2] },
properties: {
gauge_label: "Demo Pegel",
forecast_timestamp: "2026-07-09 09:00:00+02:00",
high_water_low_water: [
{
event_timestamp: "2026-07-09 10:00:00+02:00",
event: "HW",
forecast_value: 620,
forecast_deviation: "+0,2 m"
},
{
event_timestamp: "2026-07-09 15:30:00+02:00",
event: "NW",
tidal_prediction_value: "370"
}
],
curve: [
{
timestamp: "2026-07-09 10:00:00+02:00",
tidal_prediction: "620",
measurement: "618"
}
]
}
}
]
},
{ lat: 54.2, lon: 12.1 },
new Date("2026-07-09T08:00:00+02:00")
);
expect(summary?.station).toBe("Demo Pegel");
expect(summary?.nextHigh?.heightM).toBe(6.2);
expect(summary?.nextLow?.heightM).toBe(3.7);
expect(summary?.waterLevelCurve[0]?.predictedM).toBe(6.2);
});
it("uses params.at instead of wall-clock time when filtering upcoming tide events", async () => {
const cache = createCache();
const fetcher = vi.fn(async () =>
new Response(
JSON.stringify({
features: [
{
geometry: { type: "Point", coordinates: [7.18, 53.34] },
properties: {
gauge_label: "Emden",
forecast_timestamp: "2030-01-01T09:00:00.000Z",
high_water_low_water: [
{
event_timestamp: "2030-01-01T10:00:00.000Z",
event: "HW",
forecast_value: 610
},
{
event_timestamp: "2030-01-01T13:00:00.000Z",
event: "NW",
forecast_value: 350
},
{
event_timestamp: "2030-01-01T16:00:00.000Z",
event: "HW",
forecast_value: 625
}
]
}
}
]
}),
{ status: 200, headers: { "content-type": "application/json" } }
)) as unknown as FetchLike;
try {
const summary = await getNearestTideSummary(
{ lat: 53.34, lon: 7.18, at: "2030-01-01T12:00:00.000Z" },
{ cache, fetcher }
);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(summary?.nextLow).toEqual(
expect.objectContaining({ time: "2030-01-01T13:00:00.000Z", heightM: 3.5 })
);
expect(summary?.nextHigh).toEqual(
expect.objectContaining({ time: "2030-01-01T16:00:00.000Z", heightM: 6.25 })
);
} finally {
await cache.close();
}
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { normalizeMarineForecast } from "../src/services/weather.js";
describe("departure-time marine forecast", () => {
it("selects waves, wind and ocean current nearest the requested passage time", () => {
const result = normalizeMarineForecast(
{
hourly: {
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
wave_height: [0.6, 0.9],
wave_direction: [280, 290],
wave_period: [4, 5],
ocean_current_velocity: [0.4, 1.1],
ocean_current_direction: [90, 100],
sea_level_height_msl: [0.2, 0.4]
}
},
{
hourly: {
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
wind_speed_10m: [8, 12],
wind_direction_10m: [240, 250],
weather_code: [2, 3],
temperature_2m: [18, 19]
}
},
{ marineAvailable: true, weatherAvailable: true },
"2026-07-20T08:40:00.000Z"
);
expect(result).toMatchObject({
waveHeightM: 0.9,
windSpeed: 12,
oceanCurrentSpeedKn: 1.1,
oceanCurrentDirectionDeg: 100,
seaLevelHeightMslM: 0.4,
forecastTime: "2026-07-20T09:00:00.000Z"
});
});
it("does not reuse the edge of the forecast as if it covered a much later departure", () => {
const result = normalizeMarineForecast(
{
hourly: {
time: ["2026-07-20T08:00"],
wave_height: [0.6],
wave_direction: [280],
wave_period: [4],
ocean_current_velocity: [0.4],
ocean_current_direction: [90],
sea_level_height_msl: [0.2]
}
},
{
hourly: {
time: ["2026-07-20T08:00"],
wind_speed_10m: [8],
wind_direction_10m: [240],
weather_code: [2],
temperature_2m: [18]
}
},
{ marineAvailable: true, weatherAvailable: true },
"2026-07-24T08:00:00.000Z"
);
expect(result).toMatchObject({
waveHeightM: null,
windSpeed: null,
oceanCurrentSpeedKn: null,
oceanCurrentDirectionDeg: null
});
});
});