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; end: ReturnType; } { 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; end: ReturnType; }; } 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 } }); }); });