import { PluginSearchController } from '../../../src/nest/plugins/contributions/plugin-search.controller'; import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import { pluginSearchHitSchema, pluginSuggestResultSchema } from '@trek/shared'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; const hit = (over: Record = {}) => ({ id: 'a', name: 'Ristorante', lat: 45.4, lng: 9.2, ...over }); function controller(over: Partial = {}) { const hooks = { providersOf: vi.fn(() => ['p1']), searchPlaces: vi.fn(async () => [hit()]), suggestPlaces: vi.fn(async () => [hit()]), ...over, } as unknown as PluginHooks; return { c: new PluginSearchController(hooks), hooks }; } describe('PluginSearchController', () => { beforeEach(() => { pluginsEnabled.mockReturnValue(true); }); it('returns [] when the runtime is disabled (no plugin calls)', async () => { pluginsEnabled.mockReturnValue(false); const { c, hooks } = controller(); expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(hooks.providersOf).not.toHaveBeenCalled(); }); it('returns [] without an authenticated user, and for a blank query', async () => { const { c, hooks } = controller(); expect(await c.search('milan', undefined, undefined, undefined, undefined, req(undefined))).toEqual({ places: [] }); expect(await c.search(' ', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(await c.search(undefined, undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(hooks.searchPlaces).not.toHaveBeenCalled(); }); it('asks no provider when none implements the hook', async () => { const { c, hooks } = controller({ providersOf: vi.fn(() => []) }); expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(hooks.searchPlaces).not.toHaveBeenCalled(); }); it('passes the query, the bias and a capped limit, and drops a bias that is not a coordinate', async () => { const { c, hooks } = controller(); await c.search(' milan ', '45.4', '9.2', 'it', '999', req(5)); expect(hooks.searchPlaces).toHaveBeenCalledWith( 'p1', { query: 'milan', limit: 20, lang: 'it', near: { lat: 45.4, lng: 9.2 } }, 5, ); await c.search('milan', '91', '9.2', undefined, undefined, req(5)); // latitude out of range expect(hooks.searchPlaces).toHaveBeenLastCalledWith( 'p1', { query: 'milan', limit: 10, lang: undefined, near: undefined }, 5, ); }); it('skips a provider that throws and keeps the other one (graceful)', async () => { const { c } = controller({ providersOf: vi.fn(() => ['slow', 'ok']), searchPlaces: vi.fn(async (id: string) => { if (id === 'slow') throw new Error('timed out'); return [hit({ name: 'Trattoria' })]; }) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places).toHaveLength(1); expect(places[0].name).toBe('Trattoria'); expect(places[0].pluginId).toBe('ok'); }); it('interleaves two providers so neither one buries the other', async () => { const { c } = controller({ providersOf: vi.fn(() => ['a', 'b']), searchPlaces: vi.fn(async (id: string) => id === 'a' ? [hit({ id: 'a1', name: 'A1' }), hit({ id: 'a2', name: 'A2' }), hit({ id: 'a3', name: 'A3' })] : [hit({ id: 'b1', name: 'B1' })], ) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places.map((p) => p.name)).toEqual(['A1', 'B1', 'A2', 'A3']); }); it('normalizes a hit into the shape the core search returns, and namespaces the id', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ hit({ address: 'Via Roma 1', rating: 4.5, website: 'https://ok.example', phone: '+39 02', category: 'restaurant', description: 'Good', }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places[0]).toEqual({ osm_id: 'plugin:p1:a', name: 'Ristorante', address: 'Via Roma 1', lat: 45.4, lng: 9.2, rating: 4.5, website: 'https://ok.example', phone: '+39 02', category: 'restaurant', description: 'Good', source: 'plugin:p1', pluginId: 'p1', }); }); it('falls back to the coordinate when a hit carries no id of its own', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ { name: 'Nameless index', lat: 45.4, lng: 9.2 }, ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places[0].osm_id).toBe('plugin:p1:45.4,9.2'); }); it('drops a hit with no name or no place on the earth', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ { name: '', lat: 45.4, lng: 9.2 }, { name: 'No coords' }, { name: 'Off the globe', lat: 91, lng: 9.2 }, { name: 'Off the globe too', lat: 45.4, lng: 181 }, 'not an object', hit({ name: 'Keeper' }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places.map((p) => p.name)).toEqual(['Keeper']); }); it('strips a javascript: website, clamps the rating and caps every string', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ hit({ id: 'x', name: 'n'.repeat(400), address: 'a'.repeat(600), website: 'javascript:alert(1)', rating: 9.7 }), hit({ id: 'y', name: 'Low', rating: -3, website: 'http://plain.example' }), hit({ id: 'z', name: 'Unrated', rating: 'not a number' }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places[0].name).toHaveLength(200); expect(places[0].address).toHaveLength(300); expect(places[0].website).toBeNull(); expect(places[0].rating).toBe(5); expect(places[1].rating).toBe(0); expect(places[1].website).toBe('http://plain.example'); expect(places[2].rating).toBeNull(); }); it('reads an explicit null, a blank or a boolean rating as unrated, not as zero stars', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ hit({ id: 'n', rating: null }), hit({ id: 'b', rating: ' ' }), hit({ id: 't', rating: true }), hit({ id: 's', rating: '4.25' }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places.map((p) => p.rating)).toEqual([null, null, null, 4.3]); }); // #2483: a plugin index is one more source of websites typed without a scheme. it('PLUGIN-SEARCH-2483-01: a website without a scheme gains https like one from the core search', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => [ hit({ id: 'a', website: 'www.ristorante.example/menu' }), hit({ id: 'b', website: '//ristorante.example' }), hit({ id: 'c', website: 'mailto:info@ristorante.example' }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places.map((p) => p.website)).toEqual([ 'https://www.ristorante.example/menu', 'https://ristorante.example', null, ]); }); // A plugin on the same network may answer with an intranet address. The old // check kept every http(s) url a browser opens, and so does the helper. it('PLUGIN-SEARCH-2483-02: an http(s) website keeps its host whatever it looks like', async () => { const urls = ['http://localhost:8080/poi/1', 'http://intranet/poi/2', 'https://[2001:db8::1]/poi/3']; const { c } = controller({ searchPlaces: vi.fn(async () => urls.map((website, i) => hit({ id: `h${i}`, website })), ) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('poi', undefined, undefined, undefined, undefined, req(5)); expect(places.map((p) => p.website)).toEqual(urls); }); it('caps one provider at 20 hits', async () => { const { c } = controller({ searchPlaces: vi.fn(async () => Array.from({ length: 50 }, (_v, i) => hit({ id: `h${i}`, name: `H${i}` })), ) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); expect(places).toHaveLength(20); }); it('answers in the row the shared contract describes, so the client can check it strictly', async () => { // The server's SearchHit and the shared schema are two spellings of one row; this // is what fails when one of them moves without the other. const { c } = controller({ searchPlaces: vi.fn(async () => [ hit({ address: 'Via Roma 1', rating: 4.5, website: 'ok.example', phone: '+39 02', category: 'restaurant', description: 'Good', }), hit({ id: 'bare', name: 'Bare' }), ]) as unknown as PluginHooks['searchPlaces'], }); const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); for (const place of places) expect(pluginSearchHitSchema.safeParse(place).success).toBe(true); }); }); describe('PluginSearchController.suggest (#2221)', () => { beforeEach(() => { pluginsEnabled.mockReturnValue(true); }); it('asks only the providers that implement suggest, never their search', async () => { const { c, hooks } = controller(); await c.suggest('ich', '35.66', '139.7', 'ja', req(5)); expect(hooks.providersOf).toHaveBeenCalledWith('searchProvider', 'suggest'); expect(hooks.suggestPlaces).toHaveBeenCalledWith( 'p1', { query: 'ich', limit: 3, lang: 'ja', near: { lat: 35.66, lng: 139.7 } }, 5, ); expect(hooks.searchPlaces).not.toHaveBeenCalled(); }); it('asks nothing for one letter, a blank box, a caller without a session or a runtime that is off', async () => { const { c, hooks } = controller(); expect(await c.suggest('i', undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(await c.suggest(' ', undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(await c.suggest(undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(await c.suggest('ichiran', undefined, undefined, undefined, req(undefined))).toEqual({ places: [] }); pluginsEnabled.mockReturnValue(false); expect(await c.suggest('ichiran', undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(hooks.providersOf).not.toHaveBeenCalled(); expect(hooks.suggestPlaces).not.toHaveBeenCalled(); }); it('answers without reaching any plugin when none implements suggest', async () => { const { c, hooks } = controller({ providersOf: vi.fn(() => []) }); expect(await c.suggest('ichiran', undefined, undefined, undefined, req(5))).toEqual({ places: [] }); expect(hooks.suggestPlaces).not.toHaveBeenCalled(); }); it('keeps three rows across all providers, interleaved, whatever each one sends', async () => { const { c } = controller({ providersOf: vi.fn(() => ['a', 'b']), suggestPlaces: vi.fn(async (id: string) => Array.from({ length: 10 }, (_v, i) => hit({ id: `${id}${i}`, name: `${id.toUpperCase()}${i}` })), ) as unknown as PluginHooks['suggestPlaces'], }); const result = await c.suggest('ichiran', undefined, undefined, undefined, req(5)); expect(result.places.map((p) => p.name)).toEqual(['A0', 'B0', 'A1']); expect(pluginSuggestResultSchema.safeParse(result).success).toBe(true); }); it('drops a provider that times out and normalizes what the others send', async () => { const { c } = controller({ providersOf: vi.fn(() => ['slow', 'ok']), suggestPlaces: vi.fn(async (id: string) => { if (id === 'slow') throw new Error('plugin invoke timed out'); return [hit({ id: 'r1', website: 'javascript:alert(1)' }), { name: 'no coordinates' }]; }) as unknown as PluginHooks['suggestPlaces'], }); const { places } = await c.suggest('ichiran', undefined, undefined, undefined, req(5)); expect(places).toEqual([expect.objectContaining({ osm_id: 'plugin:ok:r1', source: 'plugin:ok', website: null })]); }); });