mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
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.
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 })]);
|
|
});
|
|
});
|