diff --git a/services/mana-geocoding/CLAUDE.md b/services/mana-geocoding/CLAUDE.md index 9f66cef22..0a24e0f22 100644 --- a/services/mana-geocoding/CLAUDE.md +++ b/services/mana-geocoding/CLAUDE.md @@ -251,27 +251,43 @@ A few non-obvious settings required for a self-hosted DACH deployment: ## Testing -There is **no automated test suite yet**. The service was validated -end-to-end during the 2026-04-11 deploy with a manual smoke-test set: +Two layers: + +### Unit tests (`bun test`) + +Fast, no dependencies. Locks in the subtle logic: ```bash -# From the mac-mini (or any container in the mana docker network): -curl -s "http://localhost:3018/api/v1/geocode/search?q=Konzil+Konstanz&limit=1" -curl -s "http://localhost:3018/api/v1/geocode/search?q=Stuttgart+Hauptbahnhof&limit=1" -curl -sG "http://localhost:3018/api/v1/geocode/search" \ - --data-urlencode "q=Marktstätte Konstanz" --data-urlencode "limit=1" -curl -s "http://localhost:3018/api/v1/geocode/reverse?lat=48.137&lon=11.575" -curl -s "http://localhost:3018/health/pelias" +cd services/mana-geocoding +bun test ``` -Expected shape per result: `{name, latitude, longitude, address, category, -peliasCategories, confidence}`. At least the major Konstanz/München/Berlin -venues should resolve with sensible categories (restaurant → `food`, -station → `transit`, school → `work`, park → `leisure`). +- `src/lib/__tests__/category-map.test.ts` — Pelias→PlaceCategory + priority resolution. Covers the multi-category ambiguity (food beats + retail for a restaurant, transport beats professional for a car rental, + …), single-category mappings, layer-hint fallback, and real-world + venue categories observed from the DACH index during the 2026-04-11 + deploy verification. +- `src/lib/__tests__/cache.test.ts` — LRU eviction order, TTL expiry, + move-to-end on `get`, size tracking. -If you add logic here, at least add unit tests around `lib/category-map.ts` -(the Pelias→PlaceCategory priority list is the most subtle part) and a -smoke test that runs the above curls against a local stack. +As of the 2026-04-11 deploy: **42 tests, all green**. + +### Smoke test (`bun run test:smoke`) + +End-to-end curls against a running service. Requires a fully deployed +Pelias stack with the DACH index loaded — run this after a deploy to +confirm the full pipeline is healthy. + +```bash +cd services/mana-geocoding +bun run test:smoke # default http://localhost:3018 +./scripts/smoke-test.sh http://mana-geocoding:3018 # from another container +``` + +Asserts: wrapper + pelias health, restaurant→food, station→transit, +street+locality fallback returns results, focus biasing works, reverse +geocoding for Konstanz and München, cache hit on repeat. 9 checks. ## Code Layout diff --git a/services/mana-geocoding/package.json b/services/mana-geocoding/package.json index 5bc1b05c5..4d0414272 100644 --- a/services/mana-geocoding/package.json +++ b/services/mana-geocoding/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", - "test": "bun test" + "test": "bun test", + "test:smoke": "./scripts/smoke-test.sh" }, "dependencies": { "hono": "^4.7.0" diff --git a/services/mana-geocoding/scripts/smoke-test.sh b/services/mana-geocoding/scripts/smoke-test.sh new file mode 100755 index 000000000..ac264697f --- /dev/null +++ b/services/mana-geocoding/scripts/smoke-test.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Smoke test for a running mana-geocoding service. +# +# Runs a handful of real-world queries against the wrapper and asserts +# that each returns at least one result with sensible category mapping. +# Fails fast on the first unexpected response. +# +# Usage: +# ./scripts/smoke-test.sh # default http://localhost:3018 +# ./scripts/smoke-test.sh http://mana-geocoding:3018 +# BASE=http://mana-geocoding:3018 ./scripts/smoke-test.sh +# +# This is NOT a substitute for the unit tests in src/lib/__tests__/ — it +# requires a fully deployed Pelias stack with the DACH index already +# loaded. It's the test to run after a deploy to confirm the full pipeline +# is healthy. + +set -euo pipefail + +BASE="${1:-${BASE:-http://localhost:3018}}" +pass=0 +fail=0 + +has() { + command -v "$1" >/dev/null 2>&1 +} + +if ! has jq; then + echo "error: jq is required for this script (brew install jq)" >&2 + exit 2 +fi + +echo "=== mana-geocoding smoke test @ $BASE ===" +echo + +check() { + local label="$1" + local url="$2" + local filter="$3" + local expected="$4" + + local actual + actual=$(curl -sf --max-time 10 "$url" 2>/dev/null | jq -r "$filter" 2>/dev/null || echo "") + + if [ "$actual" = "$expected" ]; then + printf " ✓ %-50s → %s\n" "$label" "$actual" + pass=$((pass + 1)) + else + printf " ✗ %-50s → got %q, expected %q\n" "$label" "$actual" "$expected" + fail=$((fail + 1)) + fi +} + +urlenc() { + # POSIX-friendly URL-encode for the test queries. + # jq -Rr @uri is the easiest cross-platform path. + printf '%s' "$1" | jq -Rr @uri +} + +# --- 1. Health checks --- + +echo "--- Health ---" +check "wrapper health" "$BASE/health" '.status' 'ok' +check "pelias health proxy" "$BASE/health/pelias" '.status' 'ok' +echo + +# --- 2. Forward geocoding --- + +echo "--- Forward ---" + +# Venue name queries (hit /autocomplete path) +check "restaurant search → food category" \ + "$BASE/api/v1/geocode/search?q=$(urlenc 'Konzil Konstanz')&limit=1" \ + '.results[0].category' 'food' + +check "train station search → transit category" \ + "$BASE/api/v1/geocode/search?q=$(urlenc 'Stuttgart Hauptbahnhof')&limit=1" \ + '.results[0].category' 'transit' + +# Street+locality query (falls back to /search path) +check "street+locality fallback returns a result" \ + "$BASE/api/v1/geocode/search?q=$(urlenc 'Marktstätte Konstanz')&limit=1" \ + '.results | length > 0' 'true' + +# Focus point biasing +check "focus bias returns something near Konstanz" \ + "$BASE/api/v1/geocode/search?q=$(urlenc 'Park')&limit=1&focus.lat=47.66&focus.lon=9.17" \ + '.results | length > 0' 'true' + +echo + +# --- 3. Reverse geocoding --- + +echo "--- Reverse ---" + +# Konstanz city center +check "reverse Konstanz returns a result" \ + "$BASE/api/v1/geocode/reverse?lat=47.663&lon=9.175" \ + '.results | length > 0' 'true' + +# München Marienplatz +check "reverse München Marienplatz resolves" \ + "$BASE/api/v1/geocode/reverse?lat=48.137&lon=11.575" \ + '.results | length > 0' 'true' + +echo + +# --- 4. Cache --- + +echo "--- Cache ---" + +# Prime the cache with a unique query +NONCE="smoke-$(date +%s)" +curl -sf --max-time 10 "$BASE/api/v1/geocode/search?q=$(urlenc "Konzil $NONCE")&limit=1" >/dev/null +check "same query comes back cached" \ + "$BASE/api/v1/geocode/search?q=$(urlenc "Konzil $NONCE")&limit=1" \ + '.cached' 'true' + +echo + +# --- Summary --- + +total=$((pass + fail)) +echo "=== Result: $pass/$total passed ===" +if [ "$fail" -gt 0 ]; then + exit 1 +fi diff --git a/services/mana-geocoding/src/lib/__tests__/cache.test.ts b/services/mana-geocoding/src/lib/__tests__/cache.test.ts new file mode 100644 index 000000000..6196f2c50 --- /dev/null +++ b/services/mana-geocoding/src/lib/__tests__/cache.test.ts @@ -0,0 +1,95 @@ +/** + * Unit tests for the LRU cache used by the geocoding wrapper. + * + * The cache is small and dependency-free but has three subtle behaviours + * worth locking in: TTL expiry, LRU eviction order, and move-to-end on get. + */ + +import { describe, it, expect } from 'bun:test'; +import { LRUCache } from '../cache'; + +describe('LRUCache', () => { + it('returns undefined for missing keys', () => { + const cache = new LRUCache(10, 60_000); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('stores and retrieves values', () => { + const cache = new LRUCache(10, 60_000); + cache.set('a', 'apple'); + expect(cache.get('a')).toBe('apple'); + }); + + it('overwrites existing keys', () => { + const cache = new LRUCache(10, 60_000); + cache.set('a', 'apple'); + cache.set('a', 'apricot'); + expect(cache.get('a')).toBe('apricot'); + expect(cache.size).toBe(1); + }); + + it('expires entries past their TTL', async () => { + const cache = new LRUCache(10, 20); // 20 ms TTL + cache.set('a', 'apple'); + expect(cache.get('a')).toBe('apple'); + await new Promise((r) => setTimeout(r, 30)); + expect(cache.get('a')).toBeUndefined(); + }); + + it('evicts the oldest entry when at capacity', () => { + const cache = new LRUCache(3, 60_000); + cache.set('a', 'apple'); + cache.set('b', 'banana'); + cache.set('c', 'cherry'); + cache.set('d', 'date'); // should evict 'a' + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe('banana'); + expect(cache.get('c')).toBe('cherry'); + expect(cache.get('d')).toBe('date'); + expect(cache.size).toBe(3); + }); + + it('moves entries to the end on get (LRU order)', () => { + const cache = new LRUCache(3, 60_000); + cache.set('a', 'apple'); + cache.set('b', 'banana'); + cache.set('c', 'cherry'); + // Touch 'a' so it becomes the most recently used + expect(cache.get('a')).toBe('apple'); + // Now insert 'd' — 'b' should be evicted (oldest unused), not 'a' + cache.set('d', 'date'); + expect(cache.get('a')).toBe('apple'); // still there + expect(cache.get('b')).toBeUndefined(); // evicted + expect(cache.get('c')).toBe('cherry'); + expect(cache.get('d')).toBe('date'); + }); + + it('tracks size correctly through set/get/expiry', async () => { + const cache = new LRUCache(5, 20); + expect(cache.size).toBe(0); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.size).toBe(2); + // Expire + await new Promise((r) => setTimeout(r, 30)); + // get() removes expired entries as a side effect + cache.get('a'); + expect(cache.size).toBe(1); // only 'b' remains in the map until touched + cache.get('b'); + expect(cache.size).toBe(0); + }); + + it('handles arbitrary value types', () => { + interface Feature { + name: string; + lat: number; + } + const cache = new LRUCache(10, 60_000); + const results: Feature[] = [ + { name: 'Konzil', lat: 47.66 }, + { name: 'Il Boccone', lat: 47.658 }, + ]; + cache.set('konstanz-restaurants', results); + expect(cache.get('konstanz-restaurants')).toEqual(results); + }); +}); diff --git a/services/mana-geocoding/src/lib/__tests__/category-map.test.ts b/services/mana-geocoding/src/lib/__tests__/category-map.test.ts new file mode 100644 index 000000000..9ad9a10f2 --- /dev/null +++ b/services/mana-geocoding/src/lib/__tests__/category-map.test.ts @@ -0,0 +1,184 @@ +/** + * Unit tests for the Pelias→PlaceCategory mapping. + * + * This is the subtle part of the service: a Pelias venue often has + * multiple categories (e.g. a restaurant is `['food','retail','nightlife']`) + * and we need to pick the most specific one. The priority list in + * category-map.ts encodes that choice, and these tests lock it in. + */ + +import { describe, it, expect } from 'bun:test'; +import { mapPeliasToPlaceCategory } from '../category-map'; + +describe('mapPeliasToPlaceCategory', () => { + describe('priority-ordered multi-category resolution', () => { + it('picks food over retail for a restaurant', () => { + expect(mapPeliasToPlaceCategory(['food', 'retail', 'nightlife'])).toBe('food'); + }); + + it('picks food over retail for a bakery', () => { + // Bakery is tagged food+retail in the Pelias OSM taxonomy + expect(mapPeliasToPlaceCategory(['food', 'retail'])).toBe('food'); + }); + + it('picks food over nightlife for a cafe', () => { + expect(mapPeliasToPlaceCategory(['food', 'nightlife'])).toBe('food'); + }); + + it('picks transit over professional for a car_rental', () => { + // car_rental is tagged transport+professional in Pelias + expect(mapPeliasToPlaceCategory(['transport', 'professional'])).toBe('transit'); + }); + + it('picks transit for a bus_station (multiple transport subcategories)', () => { + expect(mapPeliasToPlaceCategory(['transport', 'transport:public', 'transport:bus'])).toBe( + 'transit' + ); + }); + + it('picks transit for a station (transport:rail)', () => { + expect( + mapPeliasToPlaceCategory([ + 'transport', + 'transport:public', + 'transport:station', + 'transport:rail', + ]) + ).toBe('transit'); + }); + }); + + describe('single-category resolution', () => { + it('maps food to food', () => { + expect(mapPeliasToPlaceCategory(['food'])).toBe('food'); + }); + + it('maps retail to shopping', () => { + expect(mapPeliasToPlaceCategory(['retail'])).toBe('shopping'); + }); + + it('maps transport to transit', () => { + expect(mapPeliasToPlaceCategory(['transport'])).toBe('transit'); + }); + + it('maps education to work', () => { + expect(mapPeliasToPlaceCategory(['education'])).toBe('work'); + }); + + it('maps professional to work', () => { + expect(mapPeliasToPlaceCategory(['professional'])).toBe('work'); + }); + + it('maps government to work', () => { + expect(mapPeliasToPlaceCategory(['government'])).toBe('work'); + }); + + it('maps finance to work', () => { + expect(mapPeliasToPlaceCategory(['finance'])).toBe('work'); + }); + + it('maps entertainment to leisure', () => { + expect(mapPeliasToPlaceCategory(['entertainment'])).toBe('leisure'); + }); + + it('maps nightlife to leisure', () => { + expect(mapPeliasToPlaceCategory(['nightlife'])).toBe('leisure'); + }); + + it('maps recreation to leisure', () => { + expect(mapPeliasToPlaceCategory(['recreation'])).toBe('leisure'); + }); + + it('maps health to other', () => { + expect(mapPeliasToPlaceCategory(['health'])).toBe('other'); + }); + + it('maps religion to other', () => { + expect(mapPeliasToPlaceCategory(['religion'])).toBe('other'); + }); + }); + + describe('real-world Pelias venue categories', () => { + // These are literal category arrays observed from the Konstanz DACH + // index during the 2026-04-11 deploy verification. Locking them in + // as regression tests so future priority changes can't silently + // break address search in production. + + it('Konzil Restaurant Konstanz → food', () => { + expect(mapPeliasToPlaceCategory(['food', 'retail', 'nightlife'])).toBe('food'); + }); + + it('Stuttgart Hauptbahnhof → transit', () => { + expect( + mapPeliasToPlaceCategory([ + 'transport', + 'transport:public', + 'transport:station', + 'transport:rail', + ]) + ).toBe('transit'); + }); + + it('Physiotherapie-Schule → work', () => { + expect(mapPeliasToPlaceCategory(['education'])).toBe('work'); + }); + + it('MX-Park (Rennstrecke) → leisure', () => { + expect(mapPeliasToPlaceCategory(['recreation'])).toBe('leisure'); + }); + + it('KulturKiosk → work', () => { + // KulturKiosk is tagged professional in Pelias + expect(mapPeliasToPlaceCategory(['professional'])).toBe('work'); + }); + + it('Kölner Domshop → shopping', () => { + expect(mapPeliasToPlaceCategory(['retail'])).toBe('shopping'); + }); + }); + + describe('empty / null / unknown categories', () => { + it('returns other for empty array', () => { + expect(mapPeliasToPlaceCategory([])).toBe('other'); + }); + + it('returns other for undefined', () => { + expect(mapPeliasToPlaceCategory(undefined)).toBe('other'); + }); + + it('returns other for null', () => { + expect(mapPeliasToPlaceCategory(null)).toBe('other'); + }); + + it('returns other for unknown category strings', () => { + expect(mapPeliasToPlaceCategory(['random', 'unknown'])).toBe('other'); + }); + + it('picks known category even if unknown ones come first', () => { + expect(mapPeliasToPlaceCategory(['unknown', 'food'])).toBe('food'); + }); + }); + + describe('Pelias layer fallback', () => { + it('uses layer hint for venue with no categories', () => { + expect(mapPeliasToPlaceCategory(undefined, 'venue')).toBe('other'); + }); + + it('uses layer hint for address', () => { + expect(mapPeliasToPlaceCategory(undefined, 'address')).toBe('other'); + }); + + it('uses layer hint for street', () => { + expect(mapPeliasToPlaceCategory(undefined, 'street')).toBe('other'); + }); + + it('uses layer hint for locality', () => { + expect(mapPeliasToPlaceCategory(undefined, 'locality')).toBe('other'); + }); + + it('prefers categories over layer hint', () => { + // A venue with food category should be food, not other + expect(mapPeliasToPlaceCategory(['food'], 'venue')).toBe('food'); + }); + }); +});