mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
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<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 })]);
|
|
});
|
|
});
|