Files
TREK/server/tests/unit/mcp/tools-help-addons.test.ts
T
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

461 lines
17 KiB
TypeScript

/**
* Unit tests for the MCP help and addons tools:
* list_help_topics, get_help_page, search_help, list_addons.
*/
import { ADDON_IDS } from '../../../src/addons';
import { db as testDb } from '../../../src/db/database';
import { Addons } from '../../../src/db/entities/Addons.entity';
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
import { createUser } from '../../helpers/factories';
import { findRow, updateRows } from '../../helpers/factories/rows';
import { setAppSetting } from '../../helpers/factories/settings';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { createTestAddonsService } from '../../helpers/test-addons';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
// 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);
const realtime = new FakeRealtimeService();
const broadcastMock = realtime.broadcastMock;
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({ realtime, 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);
});
});
});