353 lines
12 KiB
TypeScript
353 lines
12 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("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
|
const app = await buildServer({ cache: createCache() });
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/routes",
|
|
payload: {
|
|
start: { lat: 53.344167, lon: 7.186111 },
|
|
destination: { lat: 53.563776, lon: 6.750562 },
|
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
|
}
|
|
});
|
|
const body = response.json();
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(body.routingMode).toBe("fairway");
|
|
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
|
expect(body.geometry.coordinates.length).toBeGreaterThan(20);
|
|
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
|
|
await app.close();
|
|
});
|
|
|
|
it("returns the inland fallback route from Emden to Hamm", async () => {
|
|
const app = await buildServer({ cache: createCache() });
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/routes",
|
|
payload: {
|
|
start: { lat: 53.344167, lon: 7.186111 },
|
|
destination: { lat: 51.6814536, lon: 7.8042615 },
|
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
|
}
|
|
});
|
|
const body = response.json();
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(body.distanceNm).toBeGreaterThan(145);
|
|
expect(body.distanceNm).toBeLessThan(165);
|
|
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
|
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
|
|
await app.close();
|
|
});
|
|
|
|
it("uses an extracted fairway graph before the seed graph", async () => {
|
|
const app = await buildServer({
|
|
cache: createCache(),
|
|
fairwayService: {
|
|
async getGraphsForRoute() {
|
|
return [
|
|
{
|
|
id: "test-extracted",
|
|
name: "Test Extracted Fairways",
|
|
maxSnapDistanceNm: 1,
|
|
nodes: [
|
|
{ id: "a", coordinate: { lat: 54, lon: 10 } },
|
|
{ id: "b", coordinate: { lat: 54.02, lon: 10.05 } },
|
|
{ id: "c", coordinate: { lat: 54.04, lon: 10.1 } }
|
|
],
|
|
edges: [
|
|
{
|
|
id: "ab",
|
|
name: "AB",
|
|
from: "a",
|
|
to: "b",
|
|
minDepthM: 4,
|
|
source: "test-overpass",
|
|
coordinates: [
|
|
{ lat: 54, lon: 10 },
|
|
{ lat: 54.02, lon: 10.05 }
|
|
]
|
|
},
|
|
{
|
|
id: "bc",
|
|
name: "BC",
|
|
from: "b",
|
|
to: "c",
|
|
minDepthM: 4,
|
|
source: "test-overpass",
|
|
coordinates: [
|
|
{ lat: 54.02, lon: 10.05 },
|
|
{ lat: 54.04, lon: 10.1 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
];
|
|
}
|
|
}
|
|
});
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/routes",
|
|
payload: {
|
|
start: { lat: 54, lon: 10 },
|
|
destination: { lat: 54.04, lon: 10.1 },
|
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
|
}
|
|
});
|
|
const body = response.json();
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(body.routingMode).toBe("fairway");
|
|
expect(body.dataSources).toContain("fairway-graph:test-extracted");
|
|
expect(body.dataSources).toContain("test-overpass");
|
|
await app.close();
|
|
});
|
|
|
|
it("returns distinct route alternatives when the waterway graph contains them", async () => {
|
|
const coordinate = (lat: number, lon: number) => ({ lat, lon });
|
|
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
|
|
id,
|
|
name: id,
|
|
from,
|
|
to,
|
|
coordinates,
|
|
minDepthM: 4,
|
|
source: "test-alternatives"
|
|
});
|
|
const app = await buildServer({
|
|
cache: createCache(),
|
|
fairwayService: {
|
|
async getGraphsForRoute() {
|
|
return [
|
|
{
|
|
id: "api-alternatives",
|
|
name: "API Alternativen",
|
|
maxSnapDistanceNm: 0.2,
|
|
nodes: [
|
|
{ id: "start", coordinate: coordinate(52, 7) },
|
|
{ id: "branch-in", coordinate: coordinate(52, 7.01) },
|
|
{ id: "upper", coordinate: coordinate(52.012, 7.03) },
|
|
{ id: "lower", coordinate: coordinate(51.988, 7.03) },
|
|
{ id: "branch-out", coordinate: coordinate(52, 7.05) },
|
|
{ id: "destination", coordinate: coordinate(52, 7.06) }
|
|
],
|
|
edges: [
|
|
edge("start-access", "start", "branch-in", [coordinate(52, 7), coordinate(52, 7.01)]),
|
|
edge("main", "branch-in", "branch-out", [coordinate(52, 7.01), coordinate(52, 7.05)]),
|
|
edge("upper-in", "branch-in", "upper", [coordinate(52, 7.01), coordinate(52.012, 7.03)]),
|
|
edge("upper-out", "upper", "branch-out", [coordinate(52.012, 7.03), coordinate(52, 7.05)]),
|
|
edge("lower-in", "branch-in", "lower", [coordinate(52, 7.01), coordinate(51.988, 7.03)]),
|
|
edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]),
|
|
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
|
]
|
|
}
|
|
];
|
|
}
|
|
}
|
|
});
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/routes",
|
|
payload: {
|
|
start: coordinate(52, 7),
|
|
destination: coordinate(52, 7.06),
|
|
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
|
|
}
|
|
});
|
|
const body = response.json();
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(body.name).toBe("Hauptroute");
|
|
expect(body.alternatives).toHaveLength(2);
|
|
expect(body.alternatives.map((route: { name: string }) => route.name)).toEqual(["Alternative 1", "Alternative 2"]);
|
|
await app.close();
|
|
});
|
|
});
|