trails/apps/planner/app/lib/pois.test.ts
Ullrich Schäfer b45e69885d
poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics
Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:38:03 +02:00

160 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
buildPoisQuery,
quantizeBbox,
deduplicateById,
queryPois,
fetchNearbyPois,
PoiRateLimitError,
type Poi,
} from "./pois.ts";
import { poiCategories } from "@trails-cool/map-core";
const drinkingWater = poiCategories.filter((c) => c.id === "drinking_water");
describe("buildPoisQuery", () => {
it("encodes the quantized bbox and category ids", () => {
const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
const q = buildPoisQuery(bbox, drinkingWater);
expect(q).toContain("bbox=52.500,13.300,52.600,13.500");
expect(q).toContain("categories=drinking_water");
});
it("joins multiple categories", () => {
const cats = poiCategories.filter((c) => ["drinking_water", "camping"].includes(c.id));
const q = buildPoisQuery({ south: 52.5, west: 13.3, north: 52.6, east: 13.5 }, cats);
expect(decodeURIComponent(q)).toContain("categories=drinking_water,camping");
});
it("produces identical query strings for near-identical viewports in one cell", () => {
const a = buildPoisQuery(
{ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
drinkingWater,
);
const b = buildPoisQuery(
{ south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 },
drinkingWater,
);
expect(a).toBe(b);
});
});
describe("quantizeBbox", () => {
it("snaps south/west down and north/east up", () => {
const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01);
expect(q.south).toBeCloseTo(52.5, 10);
expect(q.west).toBeCloseTo(13.3, 10);
expect(q.north).toBeCloseTo(52.6, 10);
expect(q.east).toBeCloseTo(13.5, 10);
});
});
describe("deduplicateById", () => {
it("keeps the first occurrence of each id", () => {
const pois: Poi[] = [
{ id: 1, lat: 52.5, lon: 13.4, category: "drinking_water", tags: {} },
{ id: 1, lat: 52.5, lon: 13.4, category: "camping", tags: {} },
{ id: 2, lat: 52.6, lon: 13.5, category: "shelter", tags: {} },
];
const result = deduplicateById(pois);
expect(result.map((p) => p.id)).toEqual([1, 2]);
});
});
describe("queryPois", () => {
afterEach(() => vi.unstubAllGlobals());
it("GETs /api/pois with the session header and parses the response", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
pois: [{ id: 7, lat: 52.5, lon: 13.4, name: "Brunnen", category: "drinking_water", tags: {} }],
}),
});
vi.stubGlobal("fetch", fetchSpy);
const pois = await queryPois(
{ south: 52.5, west: 13.3, north: 52.6, east: 13.5 },
drinkingWater,
"sess-1",
);
expect(pois).toHaveLength(1);
expect(pois[0]!.id).toBe(7);
const [url, opts] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/api/pois?bbox=");
expect(url).toContain("categories=drinking_water");
expect((opts.headers as Record<string, string>)["X-Trails-Session"]).toBe("sess-1");
expect(opts.method).toBe("GET");
});
it("returns [] without fetching when no categories are enabled", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const pois = await queryPois({ south: 52.5, west: 13.3, north: 52.6, east: 13.5 }, [], "s");
expect(pois).toEqual([]);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("throws PoiRateLimitError on a 429", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 429 }));
await expect(
queryPois({ south: 52.5, west: 13.3, north: 52.6, east: 13.5 }, drinkingWater, "s"),
).rejects.toBeInstanceOf(PoiRateLimitError);
});
it("throws a generic error on other non-ok statuses (e.g. 503)", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
await expect(
queryPois({ south: 52.5, west: 13.3, north: 52.6, east: 13.5 }, drinkingWater, "s"),
).rejects.toThrow("POI service error: 503");
});
it("deduplicates POIs returned across categories", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
pois: [
{ id: 1, lat: 52.5, lon: 13.4, category: "drinking_water", tags: {} },
{ id: 1, lat: 52.5, lon: 13.4, category: "toilets", tags: {} },
],
}),
}),
);
const pois = await queryPois(
{ south: 52.5, west: 13.3, north: 52.6, east: 13.5 },
drinkingWater,
"s",
);
expect(pois).toHaveLength(1);
});
});
describe("fetchNearbyPois", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ pois: [] }) }),
);
});
afterEach(() => vi.unstubAllGlobals());
it("builds a bbox around the point and calls /api/pois", async () => {
await fetchNearbyPois(50.0, 10.0, 500, drinkingWater);
expect(fetch).toHaveBeenCalledOnce();
const [url] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toMatch(/\/api\/pois\?bbox=\d+\.\d+,\d+\.\d+,\d+\.\d+,\d+\.\d+/);
expect(url).toContain("categories=drinking_water");
});
it("forwards the AbortSignal to fetch", async () => {
const controller = new AbortController();
await fetchNearbyPois(50.0, 10.0, 500, drinkingWater, controller.signal);
const [, opts] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(opts.signal).toBe(controller.signal);
});
});