Files
watermaps/apps/api/tests/api.test.ts
T
BuTzZ b98ec8bc6f
Test and publish container images / test (push) Successful in 2m27s
Test and publish container images / publish (push) Failing after 4s
Hinweise optimiert
2026-07-27 13:04:32 +02:00

789 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
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() });
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("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 NorddeichNorderney route for the reported coordinates", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return {
graphs: [
{
id: "norddeich-norderney-test",
name: "NorddeichNorderney",
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();
});
});