optimized events
This commit is contained in:
+43
-5
@@ -14,12 +14,15 @@ import {
|
||||
} from "@watermaps/shared";
|
||||
import { loadEnv, type ApiEnv } from "./env.js";
|
||||
import { createCache, type Cache } from "./services/cache.js";
|
||||
import { appConfig } from "./services/config.js";
|
||||
import { buildAppConfig } from "./services/config.js";
|
||||
import {
|
||||
FairwayService,
|
||||
type FairwayGraphLookup
|
||||
} from "./services/fairways.js";
|
||||
import { FeatureService } from "./services/features.js";
|
||||
import {
|
||||
FeatureService,
|
||||
FeatureSourceUnavailableError
|
||||
} from "./services/features.js";
|
||||
import { getNearestTideSummary } from "./services/tides.js";
|
||||
import { getMarineForecast } from "./services/weather.js";
|
||||
import type { FetchLike } from "./services/http.js";
|
||||
@@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
await fairwayService.close?.();
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
|
||||
app.get("/health", async () => ({
|
||||
ok: true,
|
||||
service: "watermaps-api",
|
||||
featureSource: featureService.sourceMode
|
||||
}));
|
||||
|
||||
app.get("/api/config", async () => appConfig);
|
||||
app.get("/ready", async (_request, reply) => {
|
||||
const featureSource = await featureService.checkReadiness();
|
||||
const body = {
|
||||
ok: featureSource.ready,
|
||||
service: "watermaps-api",
|
||||
checks: {
|
||||
features: featureSource
|
||||
}
|
||||
};
|
||||
return featureSource.ready
|
||||
? body
|
||||
: reply.code(503).send(body);
|
||||
});
|
||||
|
||||
app.get("/api/config", async () => {
|
||||
const featureSource = await featureService.checkReadiness();
|
||||
return buildAppConfig({
|
||||
featureSource: featureSource.ready ? featureSource.source : "unavailable",
|
||||
liveFairwayExtraction: env.liveOsmFairways
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/weather/marine", async (request, reply) => {
|
||||
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||
@@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
return featureService.getFeatures(parsed.data);
|
||||
try {
|
||||
return await featureService.getFeatures(parsed.data);
|
||||
} catch (error) {
|
||||
if (error instanceof FeatureSourceUnavailableError) {
|
||||
app.log.warn({ error }, "feature source unavailable");
|
||||
return reply.code(503).send({
|
||||
error: error.code,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/routes", async (request, reply) => {
|
||||
|
||||
+7
-2
@@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||
return {
|
||||
port: Number(env.PORT ?? 5174),
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
redisUrl: env.REDIS_URL,
|
||||
databaseUrl: nonEmptyString(env.DATABASE_URL),
|
||||
redisUrl: nonEmptyString(env.REDIS_URL),
|
||||
localFairwaysPath:
|
||||
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
|
||||
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
|
||||
@@ -26,3 +26,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||
env.NODE_ENV !== "test"
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | undefined) {
|
||||
const normalized = value?.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,78 @@
|
||||
import type { AppConfig } from "@watermaps/shared";
|
||||
import type { FeatureSourceMode } from "./features.js";
|
||||
|
||||
export const appConfig: AppConfig = {
|
||||
appName: "Watermaps",
|
||||
region: "Deutschland/EU",
|
||||
disclaimer:
|
||||
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
|
||||
featureFlags: {
|
||||
gpsTracking: true,
|
||||
compass: true,
|
||||
marineWeather: true,
|
||||
tides: true,
|
||||
manualRouting: true,
|
||||
liveFairwayExtraction: true,
|
||||
postgisFeatures: true,
|
||||
bridgeAndDepthOverlays: true,
|
||||
inlandWaterwayRouting: true,
|
||||
routeAlternatives: true,
|
||||
lockAndHarbourContacts: true,
|
||||
routeWaypoints: true,
|
||||
departureTimeForecasts: true,
|
||||
liveWaterLevels: true,
|
||||
voyageStages: true,
|
||||
gpxExport: true,
|
||||
offlineRoutes: true,
|
||||
offlineVisitedMapResources: true,
|
||||
routeDeviationAlarm: true,
|
||||
offlineTiles: true
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "openfreemap",
|
||||
name: "Basiskarte",
|
||||
kind: "style",
|
||||
url: "https://tiles.openfreemap.org/styles/bright",
|
||||
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
|
||||
defaultVisible: true
|
||||
},
|
||||
{
|
||||
id: "openseamap-seamarks",
|
||||
name: "Seezeichen",
|
||||
kind: "raster-tile",
|
||||
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
|
||||
defaultVisible: true,
|
||||
opacity: 0.95
|
||||
},
|
||||
{
|
||||
id: "emodnet-bathymetry",
|
||||
name: "Bathymetrie",
|
||||
kind: "wms",
|
||||
tileUrl:
|
||||
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
|
||||
attribution: "Bathymetry © EMODnet Bathymetry",
|
||||
defaultVisible: false,
|
||||
opacity: 0.55
|
||||
}
|
||||
],
|
||||
attribution: [
|
||||
"© OpenStreetMap contributors",
|
||||
"© OpenSeaMap contributors",
|
||||
"© EMODnet Bathymetry",
|
||||
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
|
||||
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
|
||||
"Weather, waves and current forecast © Open-Meteo"
|
||||
]
|
||||
type AppConfigCapabilities = {
|
||||
featureSource: FeatureSourceMode;
|
||||
liveFairwayExtraction: boolean;
|
||||
};
|
||||
|
||||
export function buildAppConfig(capabilities: AppConfigCapabilities): AppConfig {
|
||||
const postgisFeatures = capabilities.featureSource === "postgis";
|
||||
|
||||
return {
|
||||
appName: "Watermaps",
|
||||
region: "Deutschland/EU",
|
||||
disclaimer:
|
||||
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
|
||||
featureFlags: {
|
||||
gpsTracking: true,
|
||||
compass: true,
|
||||
marineWeather: true,
|
||||
tides: true,
|
||||
manualRouting: true,
|
||||
liveFairwayExtraction: capabilities.liveFairwayExtraction,
|
||||
postgisFeatures,
|
||||
bridgeAndDepthOverlays: postgisFeatures,
|
||||
inlandWaterwayRouting: true,
|
||||
routeAlternatives: true,
|
||||
lockAndHarbourContacts: postgisFeatures,
|
||||
routeEvents: postgisFeatures,
|
||||
routeWaypoints: true,
|
||||
departureTimeForecasts: true,
|
||||
liveWaterLevels: true,
|
||||
voyageStages: true,
|
||||
gpxExport: true,
|
||||
offlineRoutes: true,
|
||||
offlineVisitedMapResources: true,
|
||||
routeDeviationAlarm: true,
|
||||
offlineTiles: true
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "openfreemap",
|
||||
name: "Basiskarte",
|
||||
kind: "style",
|
||||
url: "https://tiles.openfreemap.org/styles/bright",
|
||||
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
|
||||
defaultVisible: true
|
||||
},
|
||||
{
|
||||
id: "openseamap-seamarks",
|
||||
name: "Seezeichen",
|
||||
kind: "raster-tile",
|
||||
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
|
||||
defaultVisible: true,
|
||||
opacity: 0.95
|
||||
},
|
||||
{
|
||||
id: "emodnet-bathymetry",
|
||||
name: "Bathymetrie",
|
||||
kind: "wms",
|
||||
tileUrl:
|
||||
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
|
||||
attribution: "Bathymetry © EMODnet Bathymetry",
|
||||
defaultVisible: false,
|
||||
opacity: 0.55
|
||||
}
|
||||
],
|
||||
attribution: [
|
||||
"© OpenStreetMap contributors",
|
||||
"© OpenSeaMap contributors",
|
||||
"© EMODnet Bathymetry",
|
||||
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
|
||||
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
|
||||
"Weather, waves and current forecast © Open-Meteo"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,21 @@ export type FeatureQuery = {
|
||||
layers: string[];
|
||||
};
|
||||
|
||||
export type FeatureSourceMode = "postgis" | "demo" | "unavailable";
|
||||
|
||||
export type FeatureSourceReadiness = {
|
||||
ready: boolean;
|
||||
source: FeatureSourceMode;
|
||||
detail?:
|
||||
| "not_configured"
|
||||
| "database_unreachable"
|
||||
| "schema_missing"
|
||||
| "data_missing";
|
||||
missingLayers?: Array<"harbours" | "locks" | "bridges">;
|
||||
};
|
||||
|
||||
export type FeatureDatabase = Pick<pg.Pool, "query" | "end">;
|
||||
|
||||
type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Array<{
|
||||
@@ -20,7 +35,7 @@ type FeatureCollection = {
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
metadata: {
|
||||
source: "postgis" | "demo" | "unavailable";
|
||||
source: Exclude<FeatureSourceMode, "unavailable">;
|
||||
warning?: string;
|
||||
deduplication?: {
|
||||
inputPoiCount: number;
|
||||
@@ -30,6 +45,15 @@ type FeatureCollection = {
|
||||
};
|
||||
};
|
||||
|
||||
export class FeatureSourceUnavailableError extends Error {
|
||||
readonly code = "feature_source_unavailable";
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "FeatureSourceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
const demoFeatures = [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
@@ -65,29 +89,110 @@ const demoFeatures = [
|
||||
];
|
||||
|
||||
export class FeatureService {
|
||||
private pool: pg.Pool | null;
|
||||
private pool: FeatureDatabase | null;
|
||||
private demoData: boolean;
|
||||
|
||||
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) {
|
||||
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
|
||||
constructor(
|
||||
env: Pick<ApiEnv, "databaseUrl" | "demoData">,
|
||||
dependencies: { pool?: FeatureDatabase } = {}
|
||||
) {
|
||||
this.pool =
|
||||
env.databaseUrl && !env.demoData
|
||||
? dependencies.pool ??
|
||||
new pg.Pool({
|
||||
connectionString: env.databaseUrl,
|
||||
connectionTimeoutMillis: 3_000,
|
||||
query_timeout: 3_000
|
||||
})
|
||||
: null;
|
||||
this.demoData = env.demoData;
|
||||
}
|
||||
|
||||
get sourceMode(): FeatureSourceMode {
|
||||
if (this.demoData) {
|
||||
return "demo";
|
||||
}
|
||||
return this.pool ? "postgis" : "unavailable";
|
||||
}
|
||||
|
||||
async checkReadiness(): Promise<FeatureSourceReadiness> {
|
||||
if (this.demoData) {
|
||||
return { ready: true, source: "demo" };
|
||||
}
|
||||
if (!this.pool) {
|
||||
return {
|
||||
ready: false,
|
||||
source: "unavailable",
|
||||
detail: "not_configured"
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.pool.query<{
|
||||
marine_features: string | null;
|
||||
marine_fairway_edges: string | null;
|
||||
}>(`
|
||||
SELECT
|
||||
to_regclass('public.marine_features')::text AS marine_features,
|
||||
to_regclass('public.marine_fairway_edges')::text AS marine_fairway_edges
|
||||
`);
|
||||
const schema = result.rows[0];
|
||||
if (!schema?.marine_features || !schema.marine_fairway_edges) {
|
||||
return {
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "schema_missing"
|
||||
};
|
||||
}
|
||||
|
||||
const dataResult = await this.pool.query<{
|
||||
harbours: boolean;
|
||||
locks: boolean;
|
||||
bridges: boolean;
|
||||
}>(`
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'harbours') AS harbours,
|
||||
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'locks') AS locks,
|
||||
EXISTS(SELECT 1 FROM marine_features WHERE layer = 'bridges') AS bridges
|
||||
`);
|
||||
const data = dataResult.rows[0];
|
||||
const missingLayers = (["harbours", "locks", "bridges"] as const).filter(
|
||||
(layer) => !data?.[layer]
|
||||
);
|
||||
if (missingLayers.length > 0) {
|
||||
return {
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "data_missing",
|
||||
missingLayers
|
||||
};
|
||||
}
|
||||
return { ready: true, source: "postgis" };
|
||||
} catch {
|
||||
return {
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "database_unreachable"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getFeatures(query: FeatureQuery): Promise<FeatureCollection> {
|
||||
if (this.pool && !this.demoData) {
|
||||
return this.getPostgisFeatures(query);
|
||||
try {
|
||||
return await this.getPostgisFeatures(query);
|
||||
} catch (error) {
|
||||
throw new FeatureSourceUnavailableError(
|
||||
"Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar.",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.demoData) {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
metadata: {
|
||||
source: "unavailable",
|
||||
warning:
|
||||
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
|
||||
}
|
||||
};
|
||||
throw new FeatureSourceUnavailableError(
|
||||
"Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
);
|
||||
}
|
||||
|
||||
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||
@@ -151,16 +256,6 @@ export class FeatureService {
|
||||
FROM marine_features
|
||||
WHERE layer = ANY($1::text[])
|
||||
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
|
||||
AND (
|
||||
layer <> 'bridges'
|
||||
OR properties ? 'seamark:bridge:clearance_height'
|
||||
OR properties ? 'seamark:bridge:clearance_height_safe'
|
||||
OR properties ? 'maxheight'
|
||||
OR properties ? 'maxheight:physical'
|
||||
OR lower(COALESCE(properties->>'bridge', '')) = ANY(
|
||||
ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening']
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
id::text,
|
||||
@@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI
|
||||
"seamark:lock_basin:communication_channel",
|
||||
"seamark:radio_station:channel"
|
||||
]);
|
||||
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
|
||||
const operator = firstStringProperty(sourceProperties, ["operator"]);
|
||||
const openingHours = firstStringProperty(sourceProperties, [
|
||||
"opening_hours",
|
||||
"lock:opening_hours",
|
||||
"service_times"
|
||||
]);
|
||||
const operator = firstStringProperty(sourceProperties, [
|
||||
"operator",
|
||||
"operator:name",
|
||||
"owner"
|
||||
]);
|
||||
const address = normalizedAddress(sourceProperties);
|
||||
const sourceUrl = firstStringProperty(sourceProperties, [
|
||||
"sourceUrl",
|
||||
@@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([
|
||||
"lock",
|
||||
"obstacle",
|
||||
"bridge",
|
||||
"bridge:movable",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height",
|
||||
@@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record<string, unknown>) {
|
||||
"seamark:bridge:clearance_height",
|
||||
"seamark:bridge:clearance_height_safe",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height"
|
||||
"maxheight:physical"
|
||||
];
|
||||
|
||||
for (const key of candidates) {
|
||||
|
||||
+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