Files
TREK/server/tests/unit/plugins/plugin-search.controller.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

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 })]);
});
});