mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 07:26:42 +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
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