mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
32d9f25e7f
commit
286e273b18
5 changed files with 440 additions and 17 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
127
services/mana-geocoding/scripts/smoke-test.sh
Executable file
127
services/mana-geocoding/scripts/smoke-test.sh
Executable 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
|
||||
95
services/mana-geocoding/src/lib/__tests__/cache.test.ts
Normal file
95
services/mana-geocoding/src/lib/__tests__/cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
184
services/mana-geocoding/src/lib/__tests__/category-map.test.ts
Normal file
184
services/mana-geocoding/src/lib/__tests__/category-map.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue