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("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(); }); });