test(geocoding): add unit tests + end-to-end smoke test script

**Unit tests (`bun test`, 42 checks, 0 deps)**

- `src/lib/__tests__/category-map.test.ts` locks in the Pelias→
  PlaceCategory priority resolution. Covers the ambiguous multi-category
  case (food beats retail for restaurants, transit beats professional
  for car rentals, transport:rail still maps to transit, …), the simple
  single-category paths, the layer-hint fallback, and regression cases
  from real Konstanz/Stuttgart/Köln venues observed during deploy
  verification.
- `src/lib/__tests__/cache.test.ts` covers LRU eviction order, TTL
  expiry, move-to-end on get (so frequently-read entries survive
  eviction), size tracking, and typed-value storage.

**Smoke test (`./scripts/smoke-test.sh` or `bun run test:smoke`)**

End-to-end curls against a running service, aimed at post-deploy
verification. Health endpoints, forward (venue + street fallback),
focus biasing, reverse geocoding, cache hit. 9 checks total.

Wired up as `test:smoke` in package.json so it runs alongside the
unit tests. Verified working: 42/42 unit tests green locally, 9/9
smoke checks green against the live Mac Mini deployment.

CLAUDE.md Testing section rewritten to reflect the new test layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-11 20:21:18 +02:00
parent 32d9f25e7f
commit 286e273b18
5 changed files with 440 additions and 17 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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<string>(10, 60_000);
expect(cache.get('missing')).toBeUndefined();
});
it('stores and retrieves values', () => {
const cache = new LRUCache<string>(10, 60_000);
cache.set('a', 'apple');
expect(cache.get('a')).toBe('apple');
});
it('overwrites existing keys', () => {
const cache = new LRUCache<string>(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<string>(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<string>(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<string>(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<number>(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<Feature[]>(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);
});
});

View file

@ -0,0 +1,184 @@
/**
* Unit tests for the PeliasPlaceCategory 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');
});
});
});