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

@ -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');
});
});
});