571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
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<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() });
|
||
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();
|
||
});
|
||
});
|