import { afterEach, describe, expect, it, vi } from "vitest"; import type { Coordinate, FairwayGraph } from "@watermaps/shared"; import { buildServer } from "../src/app.js"; import { loadEnv } from "../src/env.js"; import { createCache } from "../src/services/cache.js"; import { type FeatureDatabase, FeatureService } from "../src/services/features.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" } }); const demoEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "true" }); const productionEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "false" }); const testFairwayGraph = ( start: Coordinate, destination: Coordinate, id = "test-fairway" ): FairwayGraph => ({ id, name: "Test fairway", maxSnapDistanceNm: 0.25, nodes: [ { id: `${id}-start`, coordinate: start }, { id: `${id}-destination`, coordinate: destination } ], edges: [ { id: `${id}-edge`, name: "Test fairway edge", from: `${id}-start`, to: `${id}-destination`, coordinates: [start, destination], source: "test-fairway-source" } ] }); const boundedSnapSearchFixture = () => { const start = { lat: 52.00035, lon: 7.0005 }; const destination = { lat: 52, lon: 7.04 }; const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({ lat: start.lat + (index - 4) * 0.000001, lon: start.lon })); const graph: FairwayGraph = { id: "api-bounded-snap-search", name: "API bounded snap search", maxSnapDistanceNm: 1, nodes: [ { id: "junction", coordinate: { lat: 52, lon: 7 } }, ...trapCoordinates.map((coordinate, index) => ({ id: `trap-${index + 1}`, coordinate })), { id: "destination", coordinate: destination } ], edges: [ ...trapCoordinates.map((coordinate, index) => ({ id: `trap-${index + 1}`, name: `Trap ${index + 1}`, from: "junction", to: `trap-${index + 1}`, coordinates: [{ lat: 52, lon: 7 }, coordinate], oneway: true as const, source: `api-bounded-trap-${index + 1}` })), { id: "ninth-main-route", name: "Ninth main route", from: "junction", to: "destination", coordinates: [{ lat: 52, lon: 7 }, destination], oneway: true as const, source: "api-ninth-main-route" } ] }; return { start, destination, graph }; }; describe("Watermaps API", () => { afterEach(() => { vi.restoreAllMocks(); }); it("returns app config with map layers", async () => { const app = await buildServer({ cache: createCache(), env: productionEnv }); const response = await app.inject({ method: "GET", url: "/api/config" }); expect(response.statusCode).toBe(200); expect(response.json().layers).toHaveLength(3); expect(response.json().featureFlags).toMatchObject({ liveFairwayExtraction: false, postgisFeatures: false, bridgeAndDepthOverlays: false, lockAndHarbourContacts: false, routeEvents: false }); await app.close(); }); it("advertises feature and contact capabilities only for a configured PostGIS source", async () => { const pool = { query: vi.fn().mockImplementation((sql: unknown) => Promise.resolve( String(sql).includes("to_regclass") ? { rows: [ { marine_features: "marine_features", marine_fairway_edges: "marine_fairway_edges" } ] } : { rows: [{ harbours: true, locks: true, bridges: true }] } ) ), end: vi.fn().mockResolvedValue(undefined) } as unknown as FeatureDatabase; const featureService = new FeatureService( { databaseUrl: "postgres://features.test/watermaps", demoData: false }, { pool } ); const app = await buildServer({ cache: createCache(), env: { ...productionEnv, liveOsmFairways: true }, featureService }); const response = await app.inject({ method: "GET", url: "/api/config" }); expect(response.statusCode).toBe(200); expect(response.json().featureFlags).toMatchObject({ liveFairwayExtraction: true, postgisFeatures: true, bridgeAndDepthOverlays: true, lockAndHarbourContacts: true, routeEvents: true }); await app.close(); }); it("keeps route-event capabilities disabled until all event layers contain data", async () => { const pool = { query: vi.fn().mockImplementation((sql: unknown) => Promise.resolve( String(sql).includes("to_regclass") ? { rows: [ { marine_features: "marine_features", marine_fairway_edges: "marine_fairway_edges" } ] } : { rows: [{ harbours: false, locks: false, bridges: false }] } ) ), end: vi.fn().mockResolvedValue(undefined) } as unknown as FeatureDatabase; const featureService = new FeatureService( { databaseUrl: "postgres://features.test/watermaps", demoData: false }, { pool } ); const app = await buildServer({ cache: createCache(), env: productionEnv, featureService }); const config = await app.inject({ method: "GET", url: "/api/config" }); const readiness = await app.inject({ method: "GET", url: "/ready" }); expect(config.statusCode).toBe(200); expect(config.json().featureFlags).toMatchObject({ postgisFeatures: false, bridgeAndDepthOverlays: false, lockAndHarbourContacts: false, routeEvents: false }); expect(readiness.statusCode).toBe(503); expect(readiness.json().checks.features).toEqual({ ready: false, source: "postgis", detail: "data_missing", missingLayers: ["harbours", "locks", "bridges"] }); await app.close(); }); it("does not advertise PostGIS capabilities when the configured database is unreachable", async () => { const pool = { query: vi.fn().mockRejectedValue(new Error("connection refused")), end: vi.fn().mockResolvedValue(undefined) } as unknown as FeatureDatabase; const featureService = new FeatureService( { databaseUrl: "postgres://features.test/watermaps", demoData: false }, { pool } ); const app = await buildServer({ cache: createCache(), env: productionEnv, featureService }); const config = await app.inject({ method: "GET", url: "/api/config" }); const readiness = await app.inject({ method: "GET", url: "/ready" }); expect(config.statusCode).toBe(200); expect(config.json().featureFlags).toMatchObject({ postgisFeatures: false, bridgeAndDepthOverlays: false, lockAndHarbourContacts: false, routeEvents: false }); expect(readiness.statusCode).toBe(503); expect(readiness.json().checks.features).toEqual({ ready: false, source: "postgis", detail: "database_unreachable" }); await app.close(); }); it("returns 503 when no feature source is configured", async () => { const app = await buildServer({ cache: createCache(), env: productionEnv }); const response = await app.inject({ method: "GET", url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges" }); expect(response.statusCode).toBe(503); expect(response.json()).toEqual({ error: "feature_source_unavailable", message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert." }); await app.close(); }); it("returns 503 when the configured feature database query fails", async () => { const pool = { query: vi.fn().mockRejectedValue(new Error("database unavailable")), end: vi.fn().mockResolvedValue(undefined) } as unknown as FeatureDatabase; const featureService = new FeatureService( { databaseUrl: "postgres://features.test/watermaps", demoData: false }, { pool } ); const app = await buildServer({ cache: createCache(), env: productionEnv, featureService }); const response = await app.inject({ method: "GET", url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges" }); expect(response.statusCode).toBe(503); expect(response.json()).toEqual({ error: "feature_source_unavailable", message: "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar." }); await app.close(); }); it("separates liveness from feature-data readiness", async () => { const app = await buildServer({ cache: createCache(), env: productionEnv }); const health = await app.inject({ method: "GET", url: "/health" }); const readiness = await app.inject({ method: "GET", url: "/ready" }); expect(health.statusCode).toBe(200); expect(health.json()).toMatchObject({ ok: true, featureSource: "unavailable" }); expect(readiness.statusCode).toBe(503); expect(readiness.json()).toEqual({ ok: false, service: "watermaps-api", checks: { features: { ready: false, source: "unavailable", detail: "not_configured" } } }); 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("rejects routes without a known fairway instead of returning a straight line", async () => { const app = await buildServer({ cache: createCache(), env: productionEnv, fairwayService: { async getGraphsForRoute() { return { graphs: [], failures: [] }; }, 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 } } }); 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(), env: productionEnv, 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: 53.4498, lon: 7.4509 }, destination: { lat: 53.4646, lon: 7.4742 }, 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("reports a bounded dense-graph search instead of claiming that no route exists", async () => { const { start, destination, graph } = boundedSnapSearchFixture(); const app = await buildServer({ cache: createCache(), env: productionEnv, fairwayService: { async getGraphsForRoute() { return { graphs: [graph], failures: [] }; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start, destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } } }); expect(response.statusCode).toBe(422); expect(response.json().error).toBe("fairway_search_limited"); await app.close(); }); it("tries another available graph after one graph exhausts its search budget", async () => { const { start, destination, graph } = boundedSnapSearchFixture(); const app = await buildServer({ cache: createCache(), env: productionEnv, fairwayService: { async getGraphsForRoute() { return { graphs: [graph, testFairwayGraph(start, destination, "bounded-search-fallback")], failures: [] }; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start, destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } } }); expect(response.statusCode).toBe(200); expect(response.json().dataSources).toContain( "fairway-graph:bounded-search-fallback" ); expect( response.json().warnings.some( (warning: { code: string }) => warning.code === "ROUTE_SEARCH_LIMITED" ) ).toBe(true); await app.close(); }); it("returns 503 when a remaining graph cannot route and another source failed", async () => { const app = await buildServer({ cache: createCache(), env: productionEnv, fairwayService: { async getGraphsForRoute() { return { graphs: [ testFairwayGraph( { lat: 52, lon: 7 }, { lat: 52.01, lon: 7.01 }, "unrelated-live-graph" ) ], failures: [{ source: "local", error: new Error("full local index unavailable") }] }; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start: { lat: 53.4498, lon: 7.4509 }, destination: { lat: 53.4646, lon: 7.4742 }, 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("uses a routable graph even when another fairway source failed", async () => { const start = { lat: 54, lon: 10 }; const destination = { lat: 54.04, lon: 10.1 }; const app = await buildServer({ cache: createCache(), env: productionEnv, fairwayService: { async getGraphsForRoute() { return { graphs: [testFairwayGraph(start, destination, "available-live-graph")], failures: [{ source: "local", error: new Error("full local index unavailable") }] }; }, async close() {} } }); const response = await app.inject({ method: "POST", url: "/api/routes", payload: { start, destination, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } } }); expect(response.statusCode).toBe(200); expect(response.json().dataSources).toContain( "fairway-graph:available-live-graph" ); await app.close(); }); it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => { const app = await buildServer({ cache: createCache(), env: demoEnv }); 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); await app.close(); }); it("plans the Norddeich–Norderney route for the reported coordinates", async () => { const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { return { graphs: [ { 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", 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", source: "local-geofabrik-test", coordinates: [ { lat: 53.66, lon: 7.16 }, { lat: 53.7023, lon: 7.1658 } ] } ] } ], failures: [] }; }, 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(), env: demoEnv, 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.routeSnaps.start.requested).toEqual({ lat: 53.3422, lon: 7.1871 }); expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.465, lon: 7.4734 }); expect(body.geometry.coordinates[0]).toEqual([ body.routeSnaps.start.snapped.lon, body.routeSnaps.start.snapped.lat ]); expect(body.geometry.coordinates.at(-1)).toEqual([ body.routeSnaps.destination.snapped.lon, body.routeSnaps.destination.snapped.lat ]); await app.close(); }); it("returns the inland fallback route from Emden to Hamm", async () => { const app = await buildServer({ cache: createCache(), env: demoEnv }); 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.routeSnaps.destination.requested).toEqual({ lat: 51.6814536, lon: 7.8042615 }); expect(body.geometry.coordinates.at(-1)).toEqual([ body.routeSnaps.destination.snapped.lon, body.routeSnaps.destination.snapped.lat ]); await app.close(); }); it("uses an extracted fairway graph before the seed graph", async () => { const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { return { graphs: [ { 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", source: "test-overpass", coordinates: [ { lat: 54, lon: 10 }, { lat: 54.02, lon: 10.05 } ] }, { id: "bc", name: "BC", from: "b", to: "c", source: "test-overpass", coordinates: [ { lat: 54.02, lon: 10.05 }, { lat: 54.04, lon: 10.1 } ] } ] } ], failures: [] }; } } }); 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 { graphs: [ { 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.34145, 7.18585) }, { id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) }, { id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) }, { id: "shared-destination", coordinate: coordinate(53.32805, 6.9302) } ], 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)], 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)], source: "closer-but-disconnected-destination" }, { id: "shared-east", name: "Gemeinsamer lokaler Korridor Ost", from: "shared-start", to: "shared-east", coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)], 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)], 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.32805, 6.9302)], source: "local-geofabrik-germany+netherlands" } ] } ], failures: [] }; }, 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([ body.routeSnaps.start.snapped.lon, body.routeSnaps.start.snapped.lat ]); expect(body.geometry.coordinates.at(-1)).toEqual([ body.routeSnaps.destination.snapped.lon, body.routeSnaps.destination.snapped.lat ]); expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3416, lon: 7.186 }); expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.3282, lon: 6.9304 }); 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, source: "test-alternatives" }); const app = await buildServer({ cache: createCache(), fairwayService: { async getGraphsForRoute() { return { graphs: [ { 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)]) ] } ], failures: [] }; } } }); 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(); }); });