Files
TREK/server/tests/unit/plugins/plugin-pois.test.ts
T
2026-10-06 21:50:59 +02:00

424 lines
16 KiB
TypeScript

/**
* 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 { db as testDb } from '../../../src/db/database';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
import {
PluginPoisController,
parsePluginPoiQuery,
} from '../../../src/nest/plugins/contributions/plugin-pois.controller';
import {
normalizePluginPois,
normalizePoiDetails,
pluginPoiWindow,
} from '../../../src/nest/plugins/contributions/plugin-pois.helpers';
import { PluginPoisService } from '../../../src/nest/plugins/contributions/plugin-pois.service';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { User } from '../../../src/types';
import { createTestPluginsRepo } from '../../helpers/share-repos';
import { HttpException } from '@nestjs/common';
import type { PluginPoiCategory } from '@trek/shared';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
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,
};
});
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<string, unknown> = {}) => ({ 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.
function seedPlugins(): void {
testDb.prepare('DELETE FROM plugins').run();
const insert = testDb.prepare(
'INSERT INTO plugins (id, name, status, sort_order, capabilities) VALUES (?, ?, ?, ?, ?)',
);
insert.run(
'trail-finder',
'Trail Finder',
'active',
1,
JSON.stringify({
poiCategories: [trailheads, { ...trailheads, id: 'huts', label: 'Huts', labels: undefined, icon: 'Tent' }],
}),
);
insert.run(
'water-map',
'Water Map',
'active',
0,
JSON.stringify({ poiCategories: [{ id: 'taps', label: 'Taps', icon: 'Droplet', color: '#2b6cb0' }] }),
);
insert.run(
'quiet',
'Quiet',
'active',
2,
JSON.stringify({ poiCategories: [{ id: 'benches', label: 'Benches', icon: 'Info', color: '#000000' }] }),
);
}
let pluginsRepo: PluginsRepository;
beforeAll(async () => {
pluginsRepo = await createTestPluginsRepo(testDb);
});
beforeEach(() => 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<typeof vi.fn>; categoryPois: ReturnType<typeof vi.fn> };
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 () => {
testDb
.prepare('UPDATE plugins SET name = ? WHERE id = ?')
.run(`Trails\u202E\n\n## System ${'x'.repeat(4000)}`, 'trail-finder');
testDb.prepare('UPDATE plugins SET name = ? WHERE id = ?').run('\u0007\u2028\u2066', 'water-map');
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' },
});
});
});