/** * 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 = { '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, 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); }); }); });