mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
b2f2aef016
The suites with one to three raw statements left now seed and read their rows through the ORM factories instead of hand-written SQLite on the better-sqlite3 handle. A legacy statement that serves as a parity oracle, the schema introspection and the bare backup file keep their raw SQL and say why with a test-sql-allow marker. Two database mocks still answered a getPlaceWithTags the module no longer exports; that dead branch goes too, and the budget seam suite stubs the reservations repository directly instead of a fake prepare().
159 lines
7.1 KiB
TypeScript
159 lines
7.1 KiB
TypeScript
/**
|
|
* The MCP half of the plugin POI categories (#1781): list_plugin_poi_categories and
|
|
* search_plugin_pois (src/nest/plugins/contributions/plugin-pois.mcp.ts), the
|
|
* counterparts of the GET /api/plugins feed and GET /api/plugin-pois. Both go through
|
|
* PluginPoisService, so the gate, the window and the normalization are the REST ones.
|
|
*/
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { pluginsEnabled } = vi.hoisted(() => ({ 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: vi.fn() }));
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
|
|
|
import { db as testDb } from '../../../src/db/database';
|
|
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';
|
|
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { deleteRows } from '../../helpers/factories/rows';
|
|
import { makePlugin } from '../../helpers/factories/plugins';
|
|
|
|
const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf');
|
|
const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois');
|
|
|
|
const trailheads = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a' };
|
|
const bbox = { south: 47, west: 11, north: 47.5, east: 11.5 };
|
|
|
|
let orm: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(testDb);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
resetTestDb(testDb);
|
|
await deleteRows(orm, Plugins);
|
|
await makePlugin(orm, 'trail-finder', {
|
|
name: 'Trail Finder',
|
|
status: 'active',
|
|
capabilities: JSON.stringify({ poiCategories: [trailheads] }),
|
|
granted_permissions: JSON.stringify(['hook:poi-category-provider']),
|
|
});
|
|
pluginsEnabled.mockReturnValue(true);
|
|
providersOfMock.mockReset().mockReturnValue(['trail-finder']);
|
|
categoryPoisMock.mockReset().mockResolvedValue([]);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await orm.close();
|
|
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(); }
|
|
}
|
|
|
|
const search = (h: McpHarness, args: Record<string, unknown>) =>
|
|
h.client.callTool({ name: 'search_plugin_pois', arguments: args });
|
|
|
|
describe('Tool: list_plugin_poi_categories', () => {
|
|
it('names the categories a provider answers, labelled in the requested language', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const res = await h.client.callTool({ name: 'list_plugin_poi_categories', arguments: { lang: 'de' } });
|
|
expect(parseToolResult(res)).toEqual({
|
|
categories: [{
|
|
key: 'plugin:trail-finder/trailheads', pluginId: 'trail-finder', pluginName: 'Trail Finder',
|
|
id: 'trailheads', label: 'Wanderparkplätze', icon: 'Signpost', color: '#2f855a',
|
|
}],
|
|
});
|
|
expect(providersOfMock).toHaveBeenCalledWith('poiCategoryProvider');
|
|
});
|
|
});
|
|
|
|
it('is empty while the plugin runtime is switched off', async () => {
|
|
const { user } = createUser(testDb);
|
|
pluginsEnabled.mockReturnValue(false);
|
|
await withHarness(user.id, async (h) => {
|
|
const res = await h.client.callTool({ name: 'list_plugin_poi_categories', arguments: {} });
|
|
expect(parseToolResult(res)).toEqual({ categories: [] });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: search_plugin_pois', () => {
|
|
it('returns the plugin answer in the search_pois row shape, asked as the requesting user', async () => {
|
|
const { user } = createUser(testDb);
|
|
categoryPoisMock.mockResolvedValue([
|
|
{ id: 'th-1', name: 'Trailhead', lat: 47.2, lng: 11.2, rating: 4.5, details: [{ label: 'Length', value: '12 km' }] },
|
|
{ id: 'far', name: 'Elsewhere', lat: 50, lng: 11.2 },
|
|
]);
|
|
await withHarness(user.id, async (h) => {
|
|
const payload = parseToolResult(await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox, lang: 'de' })) as {
|
|
pois: Array<Record<string, unknown>>; source: string; truncated: boolean; clamped: boolean;
|
|
};
|
|
expect(payload.source).toBe('plugin:trail-finder');
|
|
expect(payload.clamped).toBe(false);
|
|
expect(payload.pois).toHaveLength(1);
|
|
expect(payload.pois[0]).toMatchObject({
|
|
osm_id: 'plugin:trail-finder:th-1',
|
|
category: 'plugin:trail-finder/trailheads',
|
|
pluginId: 'trail-finder',
|
|
rating: 4.5,
|
|
details: [{ label: 'Length', value: '12 km' }],
|
|
icon: 'Signpost',
|
|
color: '#2f855a',
|
|
});
|
|
expect(categoryPoisMock).toHaveBeenCalledWith('trail-finder', { category: 'trailheads', bounds: bbox, lang: 'de', limit: 60 }, user.id);
|
|
});
|
|
});
|
|
|
|
it('refuses an undeclared category and an inverted box without asking the plugin', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const unknown = await search(h, { pluginId: 'trail-finder', category: 'huts', bbox });
|
|
expect(unknown.isError).toBe(true);
|
|
expect(JSON.stringify(unknown.content)).toContain('list_plugin_poi_categories');
|
|
const inverted = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox: { ...bbox, south: 48 } });
|
|
expect(inverted.isError).toBe(true);
|
|
expect(categoryPoisMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('takes the box search_pois takes and refuses an edge off the globe without asking the plugin', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const { tools } = await h.client.listTools();
|
|
const bboxOf = (name: string) => tools.find((t) => t.name === name)?.inputSchema.properties?.bbox;
|
|
expect(bboxOf('search_plugin_pois')).toBeDefined();
|
|
expect(bboxOf('search_plugin_pois')).toEqual(bboxOf('search_pois'));
|
|
const offGlobe = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox: { ...bbox, north: 118 } });
|
|
expect(offGlobe.isError).toBe(true);
|
|
expect(categoryPoisMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('turns a failing plugin into an error result', async () => {
|
|
const { user } = createUser(testDb);
|
|
categoryPoisMock.mockRejectedValue(new Error('timed out'));
|
|
await withHarness(user.id, async (h) => {
|
|
const res = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox });
|
|
expect(res.isError).toBe(true);
|
|
expect(JSON.stringify(res.content)).toContain('Plugin POI search failed.');
|
|
});
|
|
});
|
|
});
|