823 lines
27 KiB
TypeScript
823 lines
27 KiB
TypeScript
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 {
|
||
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],
|
||
minDepthM: null,
|
||
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],
|
||
minDepthM: 4,
|
||
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],
|
||
minDepthM: 4,
|
||
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() });
|
||
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(), 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.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(),
|
||
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);
|
||
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 {
|
||
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",
|
||
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 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
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",
|
||
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 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
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)],
|
||
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.34145, 7.18585), 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.32805, 6.9302)],
|
||
minDepthM: null,
|
||
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,
|
||
minDepthM: 4,
|
||
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();
|
||
});
|
||
});
|