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 = { 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("reports unavailable fairway sources instead of claiming that no route exists", async () => { const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]); }, async close() {} } }); 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 } } }); expect(response.statusCode).toBe(503); expect(response.json().error).toBe("fairway_sources_unavailable"); 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("plans the Norddeich–Norderney route for the reported coordinates", async () => { const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { return [ { id: "norddeich-norderney-test", name: "Norddeich–Norderney", maxSnapDistanceNm: 0.5, nodes: [ { id: "norddeich", coordinate: { lat: 53.6234, lon: 7.1559 } }, { id: "fairway", coordinate: { lat: 53.66, lon: 7.16 } }, { id: "norderney", coordinate: { lat: 53.7023, lon: 7.1658 } } ], edges: [ { id: "norddeich-approach", name: "Norddeich Fahrwasser", from: "norddeich", to: "fairway", minDepthM: null, source: "local-geofabrik-test", coordinates: [ { lat: 53.6234, lon: 7.1559 }, { lat: 53.66, lon: 7.16 } ] }, { id: "norderney-approach", name: "Norderney Fahrwasser", from: "fairway", to: "norderney", minDepthM: null, source: "local-geofabrik-test", coordinates: [ { lat: 53.66, lon: 7.16 }, { lat: 53.7023, lon: 7.1658 } ] } ] } ]; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start: { lat: 53.6234, lon: 7.1559 }, destination: { lat: 53.7023, lon: 7.1658 }, 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.geometry.coordinates[0]).toEqual([7.1559, 53.6234]); expect(body.geometry.coordinates.at(-1)).toEqual([7.1658, 53.7023]); expect(body.distanceNm).toBeGreaterThan(4); expect(body.distanceNm).toBeLessThan(6); await app.close(); }); it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => { const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]); }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start: { lat: 53.3422, lon: 7.1871 }, destination: { lat: 53.465, lon: 7.4734 }, 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:emden-east-ems-seed"); expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]); expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3); expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3); 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("uses a shared local component for the reported Emden-Delfzijl coordinates", async () => { const coordinate = (lat: number, lon: number) => ({ lat, lon }); const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { return [ { id: "local-geofabrik-component-snap", name: "Lokaler Geofabrik-Komponententest", maxSnapDistanceNm: 0.3, nodes: [ { id: "start-decoy-a", coordinate: coordinate(53.3416, 7.186) }, { id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) }, { id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) }, { id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) }, { id: "shared-start", coordinate: coordinate(53.3395697, 7.1848883) }, { id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) }, { id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) }, { id: "shared-destination", coordinate: coordinate(53.3303531, 6.9334715) } ], edges: [ { id: "start-decoy", name: "Nähere getrennte Startkante", from: "start-decoy-a", to: "start-decoy-b", coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)], minDepthM: null, source: "closer-but-disconnected-start" }, { id: "destination-decoy", name: "Nähere getrennte Zielkante", from: "destination-decoy-a", to: "destination-decoy-b", coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)], minDepthM: null, source: "closer-but-disconnected-destination" }, { id: "shared-east", name: "Gemeinsamer lokaler Korridor Ost", from: "shared-start", to: "shared-east", coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)], minDepthM: null, source: "local-geofabrik-germany+netherlands" }, { id: "shared-south", name: "Gemeinsamer lokaler Korridor Süd", from: "shared-east", to: "shared-south", coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)], minDepthM: null, source: "local-geofabrik-germany+netherlands" }, { id: "shared-west", name: "Gemeinsamer lokaler Korridor West", from: "shared-south", to: "shared-destination", coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)], minDepthM: null, source: "local-geofabrik-germany+netherlands" } ] } ]; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start: { lat: 53.3416, lon: 7.186 }, destination: { lat: 53.3282, lon: 6.9304 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } } }); const body = response.json(); expect(response.statusCode).toBe(200); expect(body.routingMode).toBe("fairway"); expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]); expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]); expect(body.dataSources).toContain("local-geofabrik-germany+netherlands"); expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed"); expect(body.dataSources).not.toContain("closer-but-disconnected-start"); expect(body.dataSources).not.toContain("closer-but-disconnected-destination"); 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(); }); });