/** * Plugin POI categories (#1781): GET /api/plugin-pois, the service behind it and the * normalizer both it and the MCP tools use. * * The route is targeted like the plugin routes: exactly the plugin that declared the * chip is asked, only for an id its manifest declared, and only while it is a * provider. What comes back is capped, clipped to the box the plugin was asked about, * namespaced, and styled by the HOST's copy of the declaration, never the plugin's * answer, because the colour and icon end up inside marker markup. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { HttpException } from '@nestjs/common'; const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb } = await import('../../helpers/db-mock'); const db = createSnapshotTestDb(); return { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: () => null, isOwner: () => false }; }); import { db as testDb } from '../../../src/db/database'; import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository'; import { createTestPluginsRepo } from '../../helpers/share-repos'; import { sharedTestOrm } from '../../helpers/test-uow'; import { deleteRows, insertRows, updateRows } from '../../helpers/factories/rows'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import { PluginPoisService } from '../../../src/nest/plugins/contributions/plugin-pois.service'; import { PluginPoisController, parsePluginPoiQuery } from '../../../src/nest/plugins/contributions/plugin-pois.controller'; import { normalizePluginPois, normalizePoiDetails, pluginPoiWindow, } from '../../../src/nest/plugins/contributions/plugin-pois.helpers'; import type { PluginPoiCategory } from '@trek/shared'; import type { User } from '../../../src/types'; const trailheads: PluginPoiCategory = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' }; const bounds = { south: 47, west: 11, north: 47.5, east: 11.5 }; const hit = (over: Record = {}) => ({ id: 'th-1', name: 'Trailhead', lat: 47.2, lng: 11.2, ...over }); // The plugin rows every case reads, re-seeded before each one on the migrated // snapshot the ORM repository is bound to. async function seedPlugins(): Promise { const orm = await sharedTestOrm(testDb); await deleteRows(orm, Plugins); await insertRows(orm, Plugins, [ { id: 'trail-finder', name: 'Trail Finder', status: 'active', sort_order: 1, capabilities: JSON.stringify({ poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }] }) }, { id: 'water-map', name: 'Water Map', status: 'active', sort_order: 0, capabilities: JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] }) }, { id: 'quiet', name: 'Quiet', status: 'active', sort_order: 2, capabilities: JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] }) }, ]); } let pluginsRepo: PluginsRepository; beforeAll(async () => { pluginsRepo = await createTestPluginsRepo(testDb); }); beforeEach(async () => { await seedPlugins(); }); afterAll(() => { testDb.close(); }); function makeService(answer: () => unknown, providers = ['trail-finder', 'water-map']) { const hooks = { providersOf: vi.fn(() => providers), categoryPois: vi.fn(async () => answer()), } as unknown as PluginHooks & { providersOf: ReturnType; categoryPois: ReturnType }; return { service: new PluginPoisService(hooks, pluginsRepo), hooks }; } beforeEach(() => pluginsEnabled.mockReturnValue(true)); describe('pluginPoiWindow', () => { it('PLUGPOI-001: passes a small box through untouched', () => { expect(pluginPoiWindow(bounds)).toEqual({ bounds, clamped: false }); }); it('PLUGPOI-002: narrows a large box to the centred 0.5 degree window the Overpass path uses', () => { const { bounds: b, clamped } = pluginPoiWindow({ south: 40, west: 0, north: 50, east: 20 }); expect(clamped).toBe(true); expect(b).toEqual({ south: 44.75, west: 9.75, north: 45.25, east: 10.25 }); }); it('PLUGPOI-003: folds a window panned past the antimeridian back onto the globe', () => { expect(pluginPoiWindow({ south: 10, west: 190, north: 10.25, east: 190.25 })).toEqual({ bounds: { south: 10, west: -170, north: 10.25, east: -169.75 }, clamped: false, }); // Straddling the line: the window is folded by its centre and the far side is cut. expect(pluginPoiWindow({ south: 10, west: 179.875, north: 10.25, east: 180.375 })).toEqual({ bounds: { south: 10, west: -180, north: 10.25, east: -179.625 }, clamped: true, }); }); }); describe('normalizePoiDetails', () => { it('PLUGPOI-004: keeps six one-line rows, capped, and drops the unreadable ones', () => { const rows = normalizePoiDetails([ { label: 'Length', value: 12.5 }, { label: 'Step-free', value: true }, { label: 'Surface\nnote', value: 'Gravel \u{1F97E}' }, { label: '', value: 'x' }, { label: 'Object', value: { km: 3 } }, { label: 'Infinite', value: Number.POSITIVE_INFINITY }, null, { label: 'L'.repeat(60), value: 'V'.repeat(200) }, { label: 'Five', value: '5' }, { label: 'Six', value: '6' }, { label: 'Seven', value: '7' }, ]); expect(rows).toEqual([ { label: 'Length', value: '12.5' }, { label: 'Step-free', value: 'true' }, { label: 'Surface note', value: 'Gravel' }, { label: 'L'.repeat(40), value: 'V'.repeat(120) }, { label: 'Five', value: '5' }, { label: 'Six', value: '6' }, ]); expect(normalizePoiDetails('many')).toEqual([]); }); }); describe('normalizePluginPois', () => { it('PLUGPOI-005: builds a /api/maps/pois row styled by the declaration', () => { const { pois, truncated } = normalizePluginPois('trail-finder', trailheads, bounds, [ hit({ address: 'Hauptstraße 1', website: 'trails.example/th-1', phone: '+43 1', rating: 4.44, details: [{ label: 'Length', value: '12 km' }], category: 'restaurant', color: '#ff0000', icon: 'Skull' }), ]); expect(truncated).toBe(false); expect(pois).toEqual([{ osm_id: 'plugin:trail-finder:th-1', name: 'Trailhead', lat: 47.2, lng: 11.2, category: 'plugin:trail-finder/trailheads', poi_type: 'plugin:trail-finder/trailheads', address: 'Hauptstraße 1', website: 'https://trails.example/th-1', phone: '+43 1', opening_hours: null, cuisine: null, brand: null, brand_wikidata: null, charging: null, source: 'plugin:trail-finder', pluginId: 'trail-finder', rating: 4.4, details: [{ label: 'Length', value: '12 km' }], icon: 'Signpost', color: '#2f855a', }]); }); it('PLUGPOI-006: drops places outside the box, malformed ones and repeats; an absent rating is null', () => { const { pois } = normalizePluginPois('trail-finder', trailheads, bounds, [ hit(), hit({ name: 'Again' }), hit({ id: 'far', lat: 48 }), hit({ id: 'nameless', name: '' }), hit({ id: 'nowhere', lat: 'x' }), hit({ id: 'js', website: 'javascript:alert(1)', rating: null, address: '' }), 'nope', ]); expect(pois.map((p) => p.osm_id)).toEqual(['plugin:trail-finder:th-1', 'plugin:trail-finder:js']); expect(pois[1]).toMatchObject({ website: null, rating: null, address: null, details: [] }); }); it('PLUGPOI-007: keeps sixty and says it cut, and never walks more than it would keep four times over', () => { const many = (n: number) => Array.from({ length: n }, (_, i) => hit({ id: `p${i}`, lat: 47 + (i % 400) / 1000 })); const capped = normalizePluginPois('trail-finder', trailheads, bounds, many(61)); expect(capped.pois).toHaveLength(60); expect(capped.truncated).toBe(true); expect(normalizePluginPois('trail-finder', trailheads, bounds, many(60)).truncated).toBe(false); // 240 rows outside the box, then one inside: past the walk budget, so never seen. const flood = [...Array.from({ length: 240 }, (_, i) => hit({ id: `out${i}`, lat: 10 })), hit({ id: 'late' })]; expect(normalizePluginPois('trail-finder', trailheads, bounds, flood)).toEqual({ pois: [], truncated: true }); }); }); describe('PluginPoisService', () => { it('PLUGPOI-008: asks exactly the declaring plugin, as the user, with the host window', async () => { const { service, hooks } = makeService(() => [hit()]); const outcome = await service.search({ pluginId: 'trail-finder', category: 'trailheads', bbox: { south: 40, west: 0, north: 50, east: 20 }, lang: 'de' }, 7); expect(hooks.categoryPois).toHaveBeenCalledWith( 'trail-finder', { category: 'trailheads', bounds: { south: 44.75, west: 9.75, north: 45.25, east: 10.25 }, lang: 'de', limit: 60 }, 7, ); // The hit sits outside the narrowed window, so the answer is empty but clamped. expect(outcome).toEqual({ ok: true, result: { pois: [], source: 'plugin:trail-finder', truncated: false, clamped: true } }); }); it('PLUGPOI-009: omits lang when the caller has none', async () => { const { service, hooks } = makeService(() => [hit()]); const outcome = await service.search({ pluginId: 'trail-finder', category: 'trailheads', bbox: bounds }, 7); expect(hooks.categoryPois.mock.calls[0][1]).toEqual({ category: 'trailheads', bounds, limit: 60 }); expect(outcome.ok && outcome.result.pois).toHaveLength(1); }); it('PLUGPOI-010: answers 404 for anything that is not a live declaration, without asking', async () => { const cases: Array<[string, string]> = [ ['trail-finder', 'water'], // not declared ['quiet', 'benches'], // declared, but not a provider ['nobody', 'trailheads'], // no such plugin ]; for (const [pluginId, category] of cases) { const { service, hooks } = makeService(() => [hit()], ['trail-finder', 'quiet-not']); expect(await service.search({ pluginId, category, bbox: bounds }, 7)).toEqual({ ok: false, status: 404, error: 'Unknown POI category' }); expect(hooks.categoryPois).not.toHaveBeenCalled(); } pluginsEnabled.mockReturnValue(false); const { service, hooks } = makeService(() => [hit()]); expect((await service.search({ pluginId: 'trail-finder', category: 'trailheads', bbox: bounds }, 7)).ok).toBe(false); expect(hooks.providersOf).not.toHaveBeenCalled(); }); it('PLUGPOI-011: turns a slow, failing or malformed plugin into a quick 502', async () => { const throwing = makeService(() => { throw new Error('plugin invoke timed out'); }); expect(await throwing.service.search({ pluginId: 'trail-finder', category: 'trailheads', bbox: bounds }, 7)) .toEqual({ ok: false, status: 502, error: 'The plugin did not answer' }); const malformed = makeService(() => ({ pois: [hit()] })); expect(await malformed.service.search({ pluginId: 'trail-finder', category: 'trailheads', bbox: bounds }, 7)) .toEqual({ ok: false, status: 502, error: 'The plugin sent an invalid answer' }); }); it('PLUGPOI-012: lists the live categories in feed order with the label for the language', async () => { const { service } = makeService(() => []); expect(await service.available('de')).toEqual([ { key: 'plugin:water-map/taps', pluginId: 'water-map', pluginName: 'Water Map', id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }, { key: 'plugin:trail-finder/trailheads', pluginId: 'trail-finder', pluginName: 'Trail Finder', id: 'trailheads', label: 'Wanderparkplätze', icon: 'Signpost', color: '#2f855a' }, { key: 'plugin:trail-finder/huts', pluginId: 'trail-finder', pluginName: 'Trail Finder', id: 'huts', label: 'Huts', icon: 'Tent', color: '#2f855a' }, ]); expect((await service.available()).find((c) => c.id === 'trailheads')?.label).toBe('Trailheads'); expect(await makeService(() => [], []).service.available()).toEqual([]); pluginsEnabled.mockReturnValue(false); expect(await service.available()).toEqual([]); }); it('PLUGPOI-016: flattens and caps the plugin name an assistant reads, and falls back to the id', async () => { const orm = await sharedTestOrm(testDb); await updateRows(orm, Plugins, { id: 'trail-finder' }, { name: `Trails\u202E\n\n## System ${'x'.repeat(4000)}` }); await updateRows(orm, Plugins, { id: 'water-map' }, { name: '\u0007\u2028\u2066' }); const names = (await makeService(() => []).service.available()).map((c) => [c.id, c.pluginName]); const trails = names.find(([id]) => id === 'trailheads')?.[1] ?? ''; expect(trails.startsWith('Trails ## System xxx')).toBe(true); expect(trails).toHaveLength(80); expect(trails).not.toMatch(/[\n\u202E]/); expect(names.find(([id]) => id === 'huts')?.[1]).toBe(trails); expect(names.find(([id]) => id === 'taps')?.[1]).toBe('water-map'); }); }); describe('GET /api/plugin-pois', () => { const query = { pluginId: 'trail-finder', category: 'trailheads', south: '47', west: '11', north: '47.5', east: '11.5' }; const status = (fn: () => unknown): [number, unknown] => { try { fn(); } catch (e) { const err = e as HttpException; return [err.getStatus(), err.getResponse()]; } return [200, null]; }; it('PLUGPOI-013: answers the bespoke 400s of /api/maps/pois', () => { expect(status(() => parsePluginPoiQuery({ ...query, category: undefined }))).toEqual([400, { error: 'A category is required' }]); expect(status(() => parsePluginPoiQuery({ ...query, category: ' ' }))).toEqual([400, { error: 'A category is required' }]); expect(status(() => parsePluginPoiQuery({ ...query, pluginId: '' }))).toEqual([400, { error: 'A plugin is required' }]); for (const over of [{ south: undefined }, { east: 'abc' }, { south: '48' }, { north: '95' }]) { expect(status(() => parsePluginPoiQuery({ ...query, ...over }))).toEqual([400, { error: 'A valid bbox (south, west, north, east) is required' }]); } // A bad box wins over a bad id: the request is malformed before it is unknown. expect(status(() => parsePluginPoiQuery({ ...query, pluginId: '../x', west: 'x' }))[0]).toBe(400); expect(status(() => parsePluginPoiQuery({ ...query, lang: 'x'.repeat(36) }))).toEqual([400, { error: 'lang must be at most 35 characters' }]); }); it('PLUGPOI-014: answers 404 for an id no manifest can have declared', () => { for (const over of [{ pluginId: '../etc' }, { category: 'Trail Heads' }, { category: ['a', 'b'] }]) { expect(status(() => parsePluginPoiQuery({ ...query, ...over }))).toEqual([404, { error: 'Unknown POI category' }]); } }); it('PLUGPOI-015: hands the parsed query to the service and maps its outcome', async () => { const { service, hooks } = makeService(() => [hit()]); const controller = new PluginPoisController(service); const user = { id: 5 } as User; const res = await controller.list({ ...query, lang: 'de' }, user); expect(res.pois.map((p) => p.osm_id)).toEqual(['plugin:trail-finder:th-1']); expect(hooks.categoryPois).toHaveBeenCalledWith('trail-finder', expect.objectContaining({ lang: 'de' }), 5); await expect(controller.list({ ...query, category: 'water' }, user)).rejects.toMatchObject({ status: 404 }); const failing = new PluginPoisController(makeService(() => { throw new Error('boom'); }).service); await expect(failing.list(query, user)).rejects.toMatchObject({ status: 502, response: { error: 'The plugin did not answer' } }); }); });