mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
2d2f1e88bd
The three collab:link events were broadcast and landed nowhere: the links panel held its own list and never listened, so a link somebody else added showed up on the next reload. It listens now, the same shape the notes panel uses, and the events are classified in the client's WS policy, which is the ratchet that caught this. Four e2e harnesses build their own trip_files and one asserts the collab feature set, so the new message_id column and the new links flag had to reach them too. Two MCP unit tests pinned the old four-key feature object.
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
|
* Unit tests for the MCP help and addons tools:
|
|
* list_help_topics, get_help_page, list_addons.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
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(),
|
|
},
|
|
};
|
|
});
|
|
vi.mock('../../../src/nest/help/wiki', () => wiki);
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
|
import { createUser } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
|
|
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 ?? '';
|
|
}
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
wiki.getWikiIndex.mockReset();
|
|
wiki.getWikiPage.mockReset();
|
|
wiki.getWikiIndex.mockResolvedValue({ sections: SECTIONS });
|
|
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);
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = testDb.prepare('SELECT name, type FROM addons WHERE id = ?').get('budget') as {
|
|
name: string;
|
|
type: string;
|
|
};
|
|
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);
|
|
const row = testDb.prepare('SELECT name FROM photo_providers WHERE id = ?').get('immich') as {
|
|
name: string;
|
|
};
|
|
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 = new AddonsService(new DatabaseService(testDb));
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
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);
|
|
|
|
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 () => {
|
|
testDb
|
|
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')")
|
|
.run();
|
|
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);
|
|
});
|
|
});
|
|
});
|