optimized events
This commit is contained in:
+203
-1
@@ -3,6 +3,10 @@ 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 FeatureDatabase,
|
||||
FeatureService
|
||||
} from "../src/services/features.js";
|
||||
import type {
|
||||
LockOperationInfo,
|
||||
NavigationDataAdapter
|
||||
@@ -90,11 +94,209 @@ describe("Watermaps API", () => {
|
||||
});
|
||||
|
||||
it("returns app config with map layers", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().layers).toHaveLength(3);
|
||||
expect(response.json().featureFlags).toMatchObject({
|
||||
liveFairwayExtraction: false,
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("advertises feature and contact capabilities only for a configured PostGIS source", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockImplementation((sql: unknown) =>
|
||||
Promise.resolve(
|
||||
String(sql).includes("to_regclass")
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
rows: [{ harbours: true, locks: true, bridges: true }]
|
||||
}
|
||||
)
|
||||
),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: { ...productionEnv, liveOsmFairways: true },
|
||||
featureService
|
||||
});
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().featureFlags).toMatchObject({
|
||||
liveFairwayExtraction: true,
|
||||
postgisFeatures: true,
|
||||
bridgeAndDepthOverlays: true,
|
||||
lockAndHarbourContacts: true,
|
||||
routeEvents: true
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps route-event capabilities disabled until all event layers contain data", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockImplementation((sql: unknown) =>
|
||||
Promise.resolve(
|
||||
String(sql).includes("to_regclass")
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
rows: [{ harbours: false, locks: false, bridges: false }]
|
||||
}
|
||||
)
|
||||
),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const config = await app.inject({ method: "GET", url: "/api/config" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(config.statusCode).toBe(200);
|
||||
expect(config.json().featureFlags).toMatchObject({
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json().checks.features).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "data_missing",
|
||||
missingLayers: ["harbours", "locks", "bridges"]
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not advertise PostGIS capabilities when the configured database is unreachable", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockRejectedValue(new Error("connection refused")),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const config = await app.inject({ method: "GET", url: "/api/config" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(config.statusCode).toBe(200);
|
||||
expect(config.json().featureFlags).toMatchObject({
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json().checks.features).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "database_unreachable"
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when no feature source is configured", async () => {
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({
|
||||
error: "feature_source_unavailable",
|
||||
message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when the configured feature database query fails", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockRejectedValue(new Error("database unavailable")),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({
|
||||
error: "feature_source_unavailable",
|
||||
message: "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar."
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("separates liveness from feature-data readiness", async () => {
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const health = await app.inject({ method: "GET", url: "/health" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(health.json()).toMatchObject({
|
||||
ok: true,
|
||||
featureSource: "unavailable"
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json()).toEqual({
|
||||
ok: false,
|
||||
service: "watermaps-api",
|
||||
checks: {
|
||||
features: {
|
||||
ready: false,
|
||||
source: "unavailable",
|
||||
detail: "not_configured"
|
||||
}
|
||||
}
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,4 +18,15 @@ describe("API environment", () => {
|
||||
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat blank service URLs as configured capabilities", () => {
|
||||
const env = loadEnv({
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: " ",
|
||||
REDIS_URL: "\t"
|
||||
});
|
||||
|
||||
expect(env.databaseUrl).toBeUndefined();
|
||||
expect(env.redisUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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("does not expose demo markers when production disables demo data without PostGIS", async () => {
|
||||
it("fails explicitly when production has no configured feature source", async () => {
|
||||
const service = new FeatureService({
|
||||
databaseUrl: undefined,
|
||||
demoData: false
|
||||
});
|
||||
|
||||
const result = await service.getFeatures({
|
||||
bbox: [5, 50, 15, 56],
|
||||
layers: ["seamarks", "locks", "harbours"]
|
||||
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();
|
||||
});
|
||||
|
||||
expect(result.features).toEqual([]);
|
||||
expect(result.metadata.source).toBe("unavailable");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +187,23 @@ describe("marine feature normalization", () => {
|
||||
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",
|
||||
@@ -71,7 +217,7 @@ describe("marine feature normalization", () => {
|
||||
"contact:email": "schleuse@example.test",
|
||||
"seamark:lock_basin:communication_channel": "18",
|
||||
opening_hours: "24/7",
|
||||
operator: "WSV",
|
||||
"operator:name": "WSV",
|
||||
"addr:street": "Fährstraße",
|
||||
"addr:housenumber": "1",
|
||||
"addr:postcode": "59071",
|
||||
@@ -92,6 +238,20 @@ describe("marine feature normalization", () => {
|
||||
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",
|
||||
@@ -153,6 +313,23 @@ describe("marine feature normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
|
||||
Reference in New Issue
Block a user