mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
2a8071f642
A searchProvider plugin can now implement an optional suggest() beside search(). Its places then show up in the place search's typed-ahead list, after the core suggestions and marked with the plugin's name, on desktop and phone. An index that cannot take a request per keystroke leaves it out and keeps answering the full search only. The child reports which functions each hook object carries at load, so GET /api/plugin-search/suggest only asks the plugins that implement suggest: from the second character, three rows at most across all of them, 800 ms to answer. The feed marks plugins holding hook:search-provider and the client only asks the route while one is installed. A plugin row carries its whole place, so picking it skips the details lookup, which knows no plugin id. The phone's search results now name their source as the desktop ones do, and the desktop dropdown scrolls instead of running past the dialog. Addresses discussion #2221.
275 lines
13 KiB
TypeScript
275 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) }));
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
|
|
import { pluginSearchHitSchema, pluginSuggestResultSchema } from '@trek/shared';
|
|
import { PluginSearchController } from '../../../src/nest/plugins/contributions/plugin-search.controller';
|
|
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
|
|
|
|
const hit = (over: Record<string, unknown> = {}) => ({ id: 'a', name: 'Ristorante', lat: 45.4, lng: 9.2, ...over });
|
|
|
|
function controller(over: Partial<PluginHooks> = {}) {
|
|
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 })]);
|
|
});
|
|
});
|