mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
00cbc5aae3
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
/**
|
|
* Unit tests for the MCP help and addons tools:
|
|
* list_help_topics, get_help_page, search_help, list_addons.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
|
|
|
// The wiki reader is stubbed at the module boundary, the way the weather tools
|
|
// stub weather.impl: help.mcp.ts calls these functions directly, and the reader
|
|
// has its own tests in tests/unit/nest/wiki.test.ts. Stubbing keeps these cases
|
|
// off the 94 real pages in wiki/ and reaches the failure branches, which a
|
|
// directory on disk cannot produce on demand.
|
|
const { wiki } = vi.hoisted(() => {
|
|
class WikiNotFound extends Error {
|
|
status = 404;
|
|
}
|
|
return {
|
|
wiki: {
|
|
WikiNotFound,
|
|
isLocalWiki: vi.fn(() => true),
|
|
getWikiIndex: vi.fn(),
|
|
getWikiPage: vi.fn(),
|
|
getWikiAsset: vi.fn(),
|
|
searchWiki: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../../../src/nest/help/wiki', () => wiki);
|
|
|
|
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { createUser } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { createTestAddonsService } from '../../helpers/test-addons';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
import { findRow, updateRows } from '../../helpers/factories/rows';
|
|
import { setAppSetting } from '../../helpers/factories/settings';
|
|
import { Addons } from '../../../src/db/entities/Addons.entity';
|
|
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
|
|
|
const SECTIONS = [
|
|
{ title: 'Getting Started', pages: [{ title: 'Quick Start', slug: 'Quick-Start' }] },
|
|
{ title: 'Planning', pages: [{ title: 'Currencies', slug: 'Currencies' }] },
|
|
];
|
|
|
|
// 40004 characters: four over the 40000-character cap, so the second chunk is
|
|
// short enough to assert on in full.
|
|
const LONG_MARKDOWN = 'a'.repeat(40_000) + 'TAIL';
|
|
|
|
const PAGES: Record<string, { slug: string; title: string; markdown: string }> = {
|
|
'Quick-Start': {
|
|
slug: 'Quick-Start',
|
|
title: 'Quick Start',
|
|
markdown: '# Quick Start\n\nCreate a trip, then add days to it.',
|
|
},
|
|
'Plugin-Development': {
|
|
slug: 'Plugin-Development',
|
|
title: 'Plugin Development',
|
|
markdown: LONG_MARKDOWN,
|
|
},
|
|
};
|
|
|
|
interface HelpIndexPayload {
|
|
sections: { title: string; pages: { title: string; slug: string }[] }[];
|
|
}
|
|
interface HelpPagePayload {
|
|
slug: string;
|
|
title: string;
|
|
markdown: string;
|
|
truncated: boolean;
|
|
next_offset: number | null;
|
|
}
|
|
interface AddonEntry {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
enabled: boolean;
|
|
}
|
|
interface AddonsPayload {
|
|
addons: AddonEntry[];
|
|
collabFeatures: { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean };
|
|
bagTracking: boolean;
|
|
}
|
|
|
|
/** First text block of a tool result, for asserting on refusal wording. */
|
|
function toolText(result: unknown): string {
|
|
const { content } = result as { content: { type: string; text?: string }[] };
|
|
return content.find((c) => c.type === 'text')?.text ?? '';
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
wiki.getWikiIndex.mockReset();
|
|
wiki.getWikiPage.mockReset();
|
|
wiki.searchWiki.mockReset();
|
|
wiki.getWikiIndex.mockResolvedValue({ sections: SECTIONS });
|
|
wiki.searchWiki.mockResolvedValue([
|
|
{
|
|
slug: 'Quick-Start',
|
|
title: 'Quick Start',
|
|
section: 'Getting Started',
|
|
anchor: null,
|
|
heading: null,
|
|
snippet: 'Create a trip, then add days to it.',
|
|
score: 9,
|
|
},
|
|
]);
|
|
wiki.getWikiPage.mockImplementation(async (slug: string) => {
|
|
const page = PAGES[slug];
|
|
if (!page) throw new wiki.WikiNotFound(slug);
|
|
return page;
|
|
});
|
|
// resetTestDb leaves the addons table alone, so a case that flips a toggle
|
|
// would otherwise leak into the next one.
|
|
for (const id of [ADDON_IDS.BUDGET, ADDON_IDS.PACKING, ADDON_IDS.COLLAB]) {
|
|
setAddonEnabled(testDb, id, true);
|
|
}
|
|
});
|
|
|
|
let orm: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(testDb);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await orm.close();
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(
|
|
userId: number,
|
|
fn: (h: McpHarness) => Promise<void>,
|
|
scopes?: string[] | null,
|
|
) {
|
|
const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// list_help_topics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: list_help_topics', () => {
|
|
it('returns the sections the wiki reader parsed out of the sidebar', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as HelpIndexPayload;
|
|
expect(data.sections).toEqual(SECTIONS);
|
|
expect(wiki.getWikiIndex).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('answers with a refusal instead of throwing when the reader fails', async () => {
|
|
wiki.getWikiIndex.mockRejectedValue(new Error('ENOENT'));
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
|
|
expect(result.isError).toBe(true);
|
|
expect(toolText(result)).toBe('Help contents unavailable.');
|
|
});
|
|
});
|
|
|
|
it('stays registered for a token holding an unrelated scope', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const names = (await h.client.listTools()).tools.map((t) => t.name);
|
|
expect(names).toContain('list_help_topics');
|
|
expect(names).toContain('get_help_page');
|
|
const result = await h.client.callTool({ name: 'list_help_topics', arguments: {} });
|
|
expect(result.isError).toBeFalsy();
|
|
}, ['weather:read']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// get_help_page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: get_help_page', () => {
|
|
it('returns a short page whole, with nothing left to fetch', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Quick-Start' },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as HelpPagePayload;
|
|
expect(data).toEqual({
|
|
slug: 'Quick-Start',
|
|
title: 'Quick Start',
|
|
markdown: PAGES['Quick-Start'].markdown,
|
|
truncated: false,
|
|
next_offset: null,
|
|
});
|
|
expect(wiki.getWikiPage).toHaveBeenCalledWith('Quick-Start');
|
|
});
|
|
});
|
|
|
|
it('refuses a slug this instance does not ship, and says where to look', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'No-Such-Page' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(toolText(result)).toContain('No help page named "No-Such-Page"');
|
|
expect(toolText(result)).toContain('list_help_topics');
|
|
});
|
|
});
|
|
|
|
it('separates an unavailable reader from a missing page', async () => {
|
|
wiki.getWikiPage.mockRejectedValue(new Error('socket hang up'));
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Quick-Start' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(toolText(result)).toBe('Help page unavailable.');
|
|
});
|
|
});
|
|
|
|
it('caps a long page and hands back the offset to resume from', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Plugin-Development' },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as HelpPagePayload;
|
|
expect(data.markdown).toHaveLength(40_000);
|
|
expect(data.markdown).toBe(LONG_MARKDOWN.slice(0, 40_000));
|
|
expect(data.truncated).toBe(true);
|
|
expect(data.next_offset).toBe(40_000);
|
|
});
|
|
});
|
|
|
|
it('resumes from the offset the previous chunk reported', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Plugin-Development', offset: 40_000 },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as HelpPagePayload;
|
|
expect(data.markdown).toBe('TAIL');
|
|
expect(data.truncated).toBe(false);
|
|
expect(data.next_offset).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('refuses an offset past the end of the page', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Quick-Start', offset: 5000 },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(toolText(result)).toContain('past the end of "Quick-Start"');
|
|
});
|
|
});
|
|
|
|
it('serves a demo account, the way the public REST route does', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'get_help_page',
|
|
arguments: { slug: 'Quick-Start' },
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as HelpPagePayload;
|
|
expect(data.title).toBe('Quick Start');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// search_help
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: search_help', () => {
|
|
it('hands the query and limit to the wiki search and returns its hits', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'search_help', arguments: { query: 'create trip', limit: 3 } });
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as { hits: { slug: string; snippet: string }[] };
|
|
expect(data.hits).toHaveLength(1);
|
|
expect(data.hits[0].slug).toBe('Quick-Start');
|
|
expect(wiki.searchWiki).toHaveBeenCalledWith('create trip', 3);
|
|
});
|
|
});
|
|
|
|
it('rejects an empty query before the search runs', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'search_help', arguments: { query: '' } });
|
|
expect(result.isError).toBe(true);
|
|
expect(wiki.searchWiki).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('reports an unavailable search instead of throwing', async () => {
|
|
wiki.searchWiki.mockRejectedValue(new Error('ENOENT'));
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'search_help', arguments: { query: 'trip' } });
|
|
expect(result.isError).toBe(true);
|
|
expect(toolText(result)).toBe('Help search unavailable.');
|
|
});
|
|
});
|
|
|
|
it('stays registered for a token holding an unrelated scope', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const names = (await h.client.listTools()).tools.map((t) => t.name);
|
|
expect(names).toContain('search_help');
|
|
}, ['weather:read']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// list_addons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: list_addons', () => {
|
|
it('lists the enabled addons with the collab sub-features and bag tracking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const row = await findRow(orm, Addons, { id: 'budget' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as AddonsPayload;
|
|
// The whole entry, so the admin-panel chrome (icon, plus config and fields
|
|
// on a photo provider) has to be absent rather than merely unasserted.
|
|
expect(data.addons).toContainEqual({
|
|
id: 'budget',
|
|
name: row?.name,
|
|
type: row?.type,
|
|
enabled: true,
|
|
});
|
|
expect(data.collabFeatures).toEqual({ chat: true, notes: true, links: true, polls: true, whatsnext: true });
|
|
expect(data.bagTracking).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('lists the enabled photo providers alongside the addons', async () => {
|
|
const { user } = createUser(testDb);
|
|
// Providers ride the journey addon now; migration 84 seeds it off.
|
|
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
|
|
// PhotoProviderSeeder seeds immich disabled by default (enabled: 0) — flip
|
|
// it on the way the admin panel would, same idiom as setAddonEnabled above.
|
|
await updateRows(orm, PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
|
const row = await findRow(orm, PhotoProviders, { id: 'immich' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
const data = parseToolResult(result) as AddonsPayload;
|
|
expect(data.addons).toContainEqual({
|
|
id: 'immich',
|
|
name: row?.name,
|
|
type: 'photo_provider',
|
|
enabled: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('drops an addon the admin switched off, so absence means disabled', async () => {
|
|
setAddonEnabled(testDb, ADDON_IDS.BUDGET, false);
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
const data = parseToolResult(result) as AddonsPayload;
|
|
const ids = data.addons.map((a) => a.id);
|
|
expect(ids).not.toContain(ADDON_IDS.BUDGET);
|
|
expect(ids).toContain(ADDON_IDS.PACKING);
|
|
});
|
|
});
|
|
|
|
it('follows a collab sub-feature the admin toggles, in both directions', async () => {
|
|
// Written through the same service the admin panel writes through rather
|
|
// than through a hand-rolled app_settings row, so the tool is checked
|
|
// against the real writer and not against a restatement of it.
|
|
const addonsService = await createTestAddonsService(testDb);
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
await addonsService.updateCollabFeatures({ polls: false });
|
|
const off = parseToolResult(
|
|
await h.client.callTool({ name: 'list_addons', arguments: {} }),
|
|
) as AddonsPayload;
|
|
expect(off.collabFeatures.polls).toBe(false);
|
|
expect(off.collabFeatures.chat).toBe(true);
|
|
|
|
await addonsService.updateCollabFeatures({ polls: true });
|
|
const on = parseToolResult(
|
|
await h.client.callTool({ name: 'list_addons', arguments: {} }),
|
|
) as AddonsPayload;
|
|
expect(on.collabFeatures.polls).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('reports bag tracking once it is switched on', async () => {
|
|
await setAppSetting(orm, 'bag_tracking_enabled', 'true');
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
const data = parseToolResult(result) as AddonsPayload;
|
|
expect(data.bagTracking).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('stays registered for a token holding an unrelated scope', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const names = (await h.client.listTools()).tools.map((t) => t.name);
|
|
expect(names).toContain('list_addons');
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
expect(result.isError).toBeFalsy();
|
|
}, ['weather:read']);
|
|
});
|
|
|
|
it('serves a demo account, the way the authenticated REST route does', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_addons', arguments: {} });
|
|
expect(result.isError).toBeFalsy();
|
|
const data = parseToolResult(result) as AddonsPayload;
|
|
expect(data.addons.map((a) => a.id)).toContain(ADDON_IDS.PACKING);
|
|
});
|
|
});
|
|
});
|