mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
/**
|
|
* Unit tests for the search_places_via_plugins MCP tool (src/nest/plugins/
|
|
* contributions/plugin-search.mcp.ts), the MCP counterpart of GET /api/plugin-search.
|
|
*/
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
|
|
|
|
const { broadcastMock, pluginsEnabled } = vi.hoisted(() => ({
|
|
broadcastMock: vi.fn(),
|
|
pluginsEnabled: vi.fn(() => true),
|
|
}));
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
// The admin kill switch reads live env; drive it from the test instead of the process.
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
|
|
import { createUser } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
|
|
|
const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf');
|
|
const searchPlacesMock = vi.spyOn(PluginHooks.prototype, 'searchPlaces');
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
pluginsEnabled.mockReturnValue(true);
|
|
providersOfMock.mockReset().mockReturnValue([]);
|
|
searchPlacesMock.mockReset().mockResolvedValue([]);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false, scopes: null });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
interface PlacesPayload {
|
|
places: { osm_id: string; name: string; rating: number | null; pluginId: string; source: string }[];
|
|
}
|
|
|
|
const call = async (h: McpHarness, args: Record<string, unknown>) =>
|
|
h.client.callTool({ name: 'search_places_via_plugins', arguments: args });
|
|
|
|
describe('Tool: search_places_via_plugins', () => {
|
|
it('returns what the providers found, with the rating open data cannot carry', async () => {
|
|
const { user } = createUser(testDb);
|
|
providersOfMock.mockReturnValue(['guide']);
|
|
searchPlacesMock.mockResolvedValue([
|
|
{ id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6, rating: 4.6, address: 'Lakeside 1' },
|
|
]);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const payload = parseToolResult(await call(h, { query: 'hotel lausanne' })) as PlacesPayload;
|
|
expect(payload.places).toHaveLength(1);
|
|
expect(payload.places[0]).toMatchObject({
|
|
osm_id: 'plugin:guide:h1',
|
|
name: 'Hotel Bellevue',
|
|
rating: 4.6,
|
|
pluginId: 'guide',
|
|
source: 'plugin:guide',
|
|
});
|
|
expect(providersOfMock).toHaveBeenCalledWith('searchProvider');
|
|
expect(searchPlacesMock).toHaveBeenCalledWith(
|
|
'guide',
|
|
{ query: 'hotel lausanne', limit: 10, lang: undefined, near: undefined },
|
|
user.id,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('passes the bias and the limit through, and refuses a coordinate off the globe', async () => {
|
|
const { user } = createUser(testDb);
|
|
providersOfMock.mockReturnValue(['guide']);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await call(h, { query: 'hotel', near: { lat: 46.5, lng: 6.6 }, lang: 'fr', limit: 3 });
|
|
expect(searchPlacesMock).toHaveBeenLastCalledWith(
|
|
'guide',
|
|
{ query: 'hotel', limit: 3, lang: 'fr', near: { lat: 46.5, lng: 6.6 } },
|
|
user.id,
|
|
);
|
|
|
|
// The schema accepts any number, so the range check still has to happen here.
|
|
await call(h, { query: 'hotel', near: { lat: 91, lng: 6.6 } });
|
|
expect(searchPlacesMock).toHaveBeenLastCalledWith(
|
|
'guide',
|
|
{ query: 'hotel', limit: 10, lang: undefined, near: undefined },
|
|
user.id,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('answers with an empty list when no plugin provides an index, without asking one', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload;
|
|
expect(payload.places).toEqual([]);
|
|
expect(searchPlacesMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('answers with an empty list while the plugin runtime is switched off', async () => {
|
|
const { user } = createUser(testDb);
|
|
pluginsEnabled.mockReturnValue(false);
|
|
providersOfMock.mockReturnValue(['guide']);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload;
|
|
expect(payload.places).toEqual([]);
|
|
expect(providersOfMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('skips a provider that fails and still returns the other one', async () => {
|
|
const { user } = createUser(testDb);
|
|
providersOfMock.mockReturnValue(['broken', 'guide']);
|
|
searchPlacesMock.mockImplementation(async (pluginId: string) => {
|
|
if (pluginId === 'broken') throw new Error('timed out');
|
|
return [{ id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6 }];
|
|
});
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload;
|
|
expect(payload.places.map(p => p.pluginId)).toEqual(['guide']);
|
|
});
|
|
});
|
|
});
|