383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
deduplicateMarineContactFeatures,
|
|
type FeatureDatabase,
|
|
FeatureService,
|
|
FeatureSourceUnavailableError,
|
|
normalizeDepthFeatureProperties,
|
|
normalizeMarineFeatureProperties
|
|
} from "../src/services/features.js";
|
|
|
|
describe("marine feature data modes", () => {
|
|
it("fails explicitly when production has no configured feature source", async () => {
|
|
const service = new FeatureService({
|
|
databaseUrl: undefined,
|
|
demoData: false
|
|
});
|
|
|
|
await expect(
|
|
service.getFeatures({
|
|
bbox: [5, 50, 15, 56],
|
|
layers: ["seamarks", "locks", "harbours"]
|
|
})
|
|
).rejects.toBeInstanceOf(FeatureSourceUnavailableError);
|
|
expect(service.sourceMode).toBe("unavailable");
|
|
expect(await service.checkReadiness()).toEqual({
|
|
ready: false,
|
|
source: "unavailable",
|
|
detail: "not_configured"
|
|
});
|
|
await service.close();
|
|
});
|
|
|
|
it("checks that the configured PostGIS schema is ready", async () => {
|
|
const pool = featurePool(
|
|
{
|
|
rows: [
|
|
{
|
|
marine_features: "marine_features",
|
|
marine_fairway_edges: "marine_fairway_edges"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
rows: [{ harbours: true, locks: true, bridges: true }]
|
|
}
|
|
);
|
|
const service = new FeatureService(
|
|
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
|
{ pool }
|
|
);
|
|
|
|
expect(service.sourceMode).toBe("postgis");
|
|
expect(await service.checkReadiness()).toEqual({
|
|
ready: true,
|
|
source: "postgis"
|
|
});
|
|
expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("to_regclass"));
|
|
expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("EXISTS"));
|
|
await service.close();
|
|
expect(pool.end).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reports a configured database with missing feature tables as not ready", async () => {
|
|
const pool = featurePool({
|
|
rows: [{ marine_features: "marine_features", marine_fairway_edges: null }]
|
|
});
|
|
const service = new FeatureService(
|
|
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
|
{ pool }
|
|
);
|
|
|
|
expect(await service.checkReadiness()).toEqual({
|
|
ready: false,
|
|
source: "postgis",
|
|
detail: "schema_missing"
|
|
});
|
|
await service.close();
|
|
});
|
|
|
|
it("reports initialized PostGIS tables without event data as not ready", async () => {
|
|
const pool = featurePool(
|
|
{
|
|
rows: [
|
|
{
|
|
marine_features: "marine_features",
|
|
marine_fairway_edges: "marine_fairway_edges"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
rows: [{ harbours: false, locks: true, bridges: false }]
|
|
}
|
|
);
|
|
const service = new FeatureService(
|
|
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
|
{ pool }
|
|
);
|
|
|
|
expect(await service.checkReadiness()).toEqual({
|
|
ready: false,
|
|
source: "postgis",
|
|
detail: "data_missing",
|
|
missingLayers: ["harbours", "bridges"]
|
|
});
|
|
await service.close();
|
|
});
|
|
|
|
it("returns ordinary bridges from the requested bbox for route-side filtering", async () => {
|
|
const pool = featurePool({
|
|
rows: [
|
|
{
|
|
id: "bridge-ordinary",
|
|
layer: "bridges",
|
|
name: "Normale Kanalbrücke",
|
|
source: "osm",
|
|
source_id: "w100",
|
|
properties: { bridge: "yes" },
|
|
updated_at: "2026-07-20T10:00:00.000Z",
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: [
|
|
[7.2, 53.4],
|
|
[7.21, 53.4]
|
|
]
|
|
}
|
|
}
|
|
]
|
|
});
|
|
const service = new FeatureService(
|
|
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
|
{ pool }
|
|
);
|
|
|
|
const result = await service.getFeatures({
|
|
bbox: [7.1, 53.3, 7.5, 53.5],
|
|
layers: ["bridges"]
|
|
});
|
|
const sql = String(vi.mocked(pool.query).mock.calls[0]?.[0]);
|
|
|
|
expect(sql).not.toContain("properties ? 'maxheight'");
|
|
expect(sql).not.toContain("properties->>'bridge'");
|
|
expect(result.features).toEqual([
|
|
expect.objectContaining({
|
|
id: "bridge-ordinary",
|
|
properties: expect.objectContaining({
|
|
layer: "bridges",
|
|
name: "Normale Kanalbrücke",
|
|
clearance_m: null
|
|
})
|
|
})
|
|
]);
|
|
await service.close();
|
|
});
|
|
});
|
|
|
|
describe("marine feature normalization", () => {
|
|
it("formats bridge clearance labels from known OSM height tags", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "bridges",
|
|
name: "Kaiser-Wilhelm-Brücke",
|
|
source: "osm",
|
|
sourceId: "w123",
|
|
properties: {
|
|
bridge: "movable",
|
|
maxheight: "3"
|
|
}
|
|
});
|
|
|
|
expect(properties.clearance_m).toBe(3);
|
|
expect(properties.clearance_label).toBe("H 3 m");
|
|
expect(properties.label).toBe("Kaiser-Wilhelm-Brücke H 3 m");
|
|
});
|
|
|
|
it("ignores non-numeric default bridge heights", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "bridges",
|
|
name: null,
|
|
source: "osm",
|
|
sourceId: "w124",
|
|
properties: {
|
|
bridge: "yes",
|
|
maxheight: "default"
|
|
}
|
|
});
|
|
|
|
expect(properties.clearance_m).toBeNull();
|
|
expect(properties.label).toBeNull();
|
|
});
|
|
|
|
it("does not mistake a bridge structure height for navigable clearance", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "bridges",
|
|
name: "Klappbrücke",
|
|
source: "osm",
|
|
sourceId: "w125",
|
|
properties: {
|
|
bridge: "movable",
|
|
height: "18"
|
|
}
|
|
});
|
|
|
|
expect(properties.clearance_m).toBeNull();
|
|
expect(properties.clearance_label).toBeNull();
|
|
expect(properties.label).toBe("Klappbrücke");
|
|
});
|
|
|
|
it("normalizes contact aliases, address and database timestamps", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "locks",
|
|
name: "Schleuse Hamm",
|
|
source: "osm",
|
|
sourceId: "w166568834",
|
|
updatedAt: new Date("2026-07-19T08:30:00.000Z"),
|
|
properties: {
|
|
"contact:phone": "+49 2381 9019280",
|
|
"contact:website": "https://example.test/schleuse-hamm",
|
|
"contact:email": "schleuse@example.test",
|
|
"seamark:lock_basin:communication_channel": "18",
|
|
opening_hours: "24/7",
|
|
"operator:name": "WSV",
|
|
"addr:street": "Fährstraße",
|
|
"addr:housenumber": "1",
|
|
"addr:postcode": "59071",
|
|
"addr:city": "Hamm",
|
|
"addr:country": "DE"
|
|
}
|
|
});
|
|
|
|
expect(properties.phone).toBe("+49 2381 9019280");
|
|
expect(properties.website).toBe("https://example.test/schleuse-hamm");
|
|
expect(properties.email).toBe("schleuse@example.test");
|
|
expect(properties.vhf).toBe("18");
|
|
expect(properties.openingHours).toBe("24/7");
|
|
expect(properties.operator).toBe("WSV");
|
|
expect(properties.address).toBe("Fährstraße 1, 59071 Hamm, DE");
|
|
expect(properties.source).toBe("osm");
|
|
expect(properties.sourceId).toBe("w166568834");
|
|
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
|
|
});
|
|
|
|
it("normalizes lock-specific opening hours for event details", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "locks",
|
|
name: "Schleuse Rahe",
|
|
source: "osm",
|
|
sourceId: "w126",
|
|
properties: {
|
|
"lock:opening_hours": "Mo-Su 07:00-20:00"
|
|
}
|
|
});
|
|
|
|
expect(properties.openingHours).toBe("Mo-Su 07:00-20:00");
|
|
});
|
|
|
|
it("prefers direct contact fields and returns stable null values when details are absent", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "harbours",
|
|
name: "Marina Emden",
|
|
source: "osm",
|
|
sourceId: "n1",
|
|
properties: {
|
|
phone: "+49 4921 123",
|
|
"contact:phone": "+49 4921 999"
|
|
}
|
|
});
|
|
|
|
expect(properties.phone).toBe("+49 4921 123");
|
|
expect(properties.website).toBeNull();
|
|
expect(properties.email).toBeNull();
|
|
expect(properties.vhf).toBeNull();
|
|
expect(properties.openingHours).toBeNull();
|
|
expect(properties.operator).toBeNull();
|
|
expect(properties.address).toBeNull();
|
|
expect(properties.updatedAt).toBeNull();
|
|
});
|
|
|
|
it("keeps navigation details but removes unrelated bulk OSM tags from viewport features", () => {
|
|
const properties = normalizeMarineFeatureProperties({
|
|
layer: "harbours",
|
|
name: "Testhafen",
|
|
source: "osm",
|
|
sourceId: "w42",
|
|
properties: {
|
|
leisure: "marina",
|
|
electricity: "yes",
|
|
"contact:phone": "+49 40 123",
|
|
"source:geometry": "survey",
|
|
note: "A very large unrelated note that is not consumed by the client"
|
|
}
|
|
});
|
|
|
|
expect(properties.leisure).toBe("marina");
|
|
expect(properties.electricity).toBe("yes");
|
|
expect(properties.phone).toBe("+49 40 123");
|
|
expect(properties).not.toHaveProperty("source:geometry");
|
|
expect(properties).not.toHaveProperty("note");
|
|
});
|
|
|
|
it("formats fairway depth labels", () => {
|
|
const properties = normalizeDepthFeatureProperties({
|
|
name: "Nord-Ostsee-Kanal",
|
|
source: "osm",
|
|
sourceId: "w456",
|
|
minDepthM: "14",
|
|
properties: {
|
|
depth: "14"
|
|
}
|
|
});
|
|
|
|
expect(properties.depth_m).toBe(14);
|
|
expect(properties.depth_label).toBe("14 m");
|
|
expect(properties.label).toBe("Nord-Ostsee-Kanal 14 m");
|
|
});
|
|
});
|
|
|
|
function featurePool(...results: Array<{ rows: unknown[] }>): FeatureDatabase & {
|
|
query: ReturnType<typeof vi.fn>;
|
|
end: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const query = vi.fn();
|
|
for (const result of results) {
|
|
query.mockResolvedValueOnce(result);
|
|
}
|
|
return {
|
|
query,
|
|
end: vi.fn().mockResolvedValue(undefined)
|
|
} as unknown as FeatureDatabase & {
|
|
query: ReturnType<typeof vi.fn>;
|
|
end: ReturnType<typeof vi.fn>;
|
|
};
|
|
}
|
|
|
|
describe("marine contact feature deduplication", () => {
|
|
it("returns one stable facility feature and reports how many raw objects were merged", () => {
|
|
const result = deduplicateMarineContactFeatures([
|
|
{
|
|
type: "Feature",
|
|
id: "12",
|
|
geometry: { type: "Point", coordinates: [7.867, 51.695] },
|
|
properties: {
|
|
layer: "locks",
|
|
source: "osm",
|
|
sourceId: "w12",
|
|
name: "Schleuse Werries",
|
|
website: "https://example.test/werries"
|
|
}
|
|
},
|
|
{
|
|
type: "Feature",
|
|
id: "99",
|
|
geometry: { type: "Point", coordinates: [7.86708, 51.69508] },
|
|
properties: {
|
|
layer: "locks",
|
|
source: "euris",
|
|
sourceId: "DEHMM00301LOCKS00404",
|
|
name: "Werries",
|
|
phone: "+49 2381 9019-290",
|
|
vhf: "22",
|
|
"ref:EU:RIS": "DEHMM00301LOCKS00404"
|
|
}
|
|
},
|
|
{
|
|
type: "Feature",
|
|
id: "bridge-1",
|
|
geometry: { type: "LineString", coordinates: [[7.8, 51.6], [7.9, 51.7]] },
|
|
properties: { layer: "bridges", source: "osm", name: "Testbrücke" }
|
|
}
|
|
]);
|
|
|
|
expect(result.metadata).toEqual({ inputPoiCount: 2, outputPoiCount: 1, mergedObjectCount: 1 });
|
|
expect(result.features).toHaveLength(2);
|
|
expect(result.features.find((feature) => feature.properties.layer === "locks")).toMatchObject({
|
|
id: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
|
|
properties: {
|
|
name: "Werries",
|
|
phone: "+49 2381 9019-290",
|
|
website: "https://example.test/werries",
|
|
dedupeMemberCount: 2
|
|
}
|
|
});
|
|
});
|
|
});
|