mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
954 lines
40 KiB
TypeScript
954 lines
40 KiB
TypeScript
/**
|
|
* Unit tests for MCP journey write tools focused on response hydration:
|
|
* create_journey returns the full journey (entries/contributors/trips/stats/my_role),
|
|
* and create_journey_entry returns the enriched entry (parsed tags, photos array).
|
|
*/
|
|
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());
|
|
});
|
|
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, broadcastToUser: broadcastMock }));
|
|
|
|
/*
|
|
* get_journey_stats resolves a country per stop. The real lookup loads and
|
|
* indexes 4MB of gzipped admin-0 boundaries on first call, which is Atlas'
|
|
* work and has its own tests (journey-stats.service.test.ts stubs it for the
|
|
* same reason). Only this one export is replaced: AtlasService is constructed
|
|
* by the harness and imports the rest of the module for real.
|
|
*/
|
|
vi.mock('../../../src/nest/atlas/atlas-geo', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
// Rough boxes, enough to tell two countries apart in a fixture.
|
|
getCountryFromCoords: (lat: number, lng: number) => {
|
|
if (lat > 47 && lat < 55 && lng > 5 && lng < 15) return 'DE';
|
|
if (lat > 42 && lat < 51 && lng > -5 && lng < 8) return 'FR';
|
|
return null;
|
|
},
|
|
}));
|
|
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { setAddonEnabled } from '../../helpers/test-db';
|
|
import { ADDON_IDS } from '../../../src/addons';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
// The `when:` gate reads the injected AddonsService, so the toggle is the row
|
|
// the admin panel writes — and this addon ships disabled by default.
|
|
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, true);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
describe('Tool: create_journey', () => {
|
|
it('returns the fully-hydrated journey, not a bare row', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_journey',
|
|
arguments: { title: 'Eurotrip', subtitle: '2026' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.journey.title).toBe('Eurotrip');
|
|
// hydrated shape from getJourneyFull
|
|
expect(Array.isArray(data.journey.entries)).toBe(true);
|
|
expect(Array.isArray(data.journey.contributors)).toBe(true);
|
|
expect(Array.isArray(data.journey.trips)).toBe(true);
|
|
expect(data.journey.stats).toBeDefined();
|
|
expect(data.journey.my_role).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: create_journey_entry', () => {
|
|
it('returns the enriched entry with parsed tags and a photos array', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey', arguments: { title: 'J' },
|
|
})) as any).journey;
|
|
const result = await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1', story: 'Arrived' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.entry.title).toBe('Day 1');
|
|
// listEntries enrichment: tags parsed to an array, photos present
|
|
expect(Array.isArray(data.entry.tags)).toBe(true);
|
|
expect(Array.isArray(data.entry.photos)).toBe(true);
|
|
expect(data.entry).toHaveProperty('source_trip_name');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: update_journey_entry', () => {
|
|
it('returns the enriched entry (parsed tags, photos array)', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey', arguments: { title: 'J' },
|
|
})) as any).journey;
|
|
const entry = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1' },
|
|
})) as any).entry;
|
|
const result = await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: { entryId: entry.id, title: 'Day 1 (edited)' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.entry.title).toBe('Day 1 (edited)');
|
|
expect(Array.isArray(data.entry.tags)).toBe(true);
|
|
expect(Array.isArray(data.entry.photos)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: update_journey_preferences', () => {
|
|
it('returns the updated preference, not { success }', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey', arguments: { title: 'J' },
|
|
})) as any).journey;
|
|
const result = await h.client.callTool({
|
|
name: 'update_journey_preferences',
|
|
arguments: { journeyId: journey.id, hide_skeletons: true },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.hide_skeletons).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The rest of the surface. The legacy registrar had no test file of its own —
|
|
// the two hydration cases above were the whole of it — so every tool below is
|
|
// covered here for the first time: the happy path, the "not found or access
|
|
// denied" branch a non-member hits, and the demo-mode denial on writes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A journey owned by someone else — every access check must refuse it. */
|
|
function foreignJourney() {
|
|
const { user: other } = createUser(testDb);
|
|
const j = testDb.prepare(
|
|
'INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
|
).run(other.id, 'Not yours', 'draft', Date.now(), Date.now());
|
|
return { otherId: other.id, journeyId: Number(j.lastInsertRowid) };
|
|
}
|
|
|
|
async function seedJourney(h: McpHarness, title = 'J') {
|
|
return (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey', arguments: { title },
|
|
})) as any).journey;
|
|
}
|
|
|
|
describe('journey read tools', () => {
|
|
it('list_journeys returns the caller-visible journeys', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
await seedJourney(h, 'Alpha');
|
|
const data = parseToolResult(await h.client.callTool({ name: 'list_journeys', arguments: {} })) as any;
|
|
expect(data.journeys.map((j: any) => j.title)).toContain('Alpha');
|
|
});
|
|
});
|
|
|
|
it('get_journey hydrates entries, contributors and trips', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(data.journey.id).toBe(journey.id);
|
|
expect(Array.isArray(data.journey.entries)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('get_journey refuses a journey the caller cannot see', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'get_journey', arguments: { journeyId } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('list_journey_entries returns the entries', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'D1' },
|
|
});
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'list_journey_entries', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(data.entries).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it('list_journey_entries refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('list_journey_contributors returns the contributor rows', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'list_journey_contributors', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(Array.isArray(data.contributors)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('list_journey_contributors refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'list_journey_contributors', arguments: { journeyId } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('get_journey_suggestions and list_journey_available_trips answer with arrays', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const s = parseToolResult(await h.client.callTool({ name: 'get_journey_suggestions', arguments: {} })) as any;
|
|
const t = parseToolResult(await h.client.callTool({ name: 'list_journey_available_trips', arguments: {} })) as any;
|
|
expect(Array.isArray(s.trips)).toBe(true);
|
|
expect(Array.isArray(t.trips)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('journey write tools', () => {
|
|
it('update_journey applies the change and refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const updated = parseToolResult(await h.client.callTool({
|
|
name: 'update_journey', arguments: { journeyId: journey.id, title: 'Renamed', status: 'active' },
|
|
})) as any;
|
|
expect(updated.journey.title).toBe('Renamed');
|
|
const denied = await h.client.callTool({ name: 'update_journey', arguments: { journeyId, title: 'X' } });
|
|
expect(denied.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('delete_journey removes it and refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const gone = parseToolResult(await h.client.callTool({
|
|
name: 'delete_journey', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(gone.success).toBe(true);
|
|
const denied = await h.client.callTool({ name: 'delete_journey', arguments: { journeyId } });
|
|
expect(denied.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('add_journey_trip links a trip, remove_journey_trip unlinks it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const added = parseToolResult(await h.client.callTool({
|
|
name: 'add_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
|
|
})) as any;
|
|
expect(added.success).toBe(true);
|
|
const removed = parseToolResult(await h.client.callTool({
|
|
name: 'remove_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
|
|
})) as any;
|
|
expect(removed.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('add_journey_trip refuses a foreign journey; remove_journey_trip is idempotent but refuses one', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
expect((await h.client.callTool({
|
|
name: 'add_journey_trip', arguments: { journeyId, tripId: trip.id },
|
|
})).isError).toBe(true);
|
|
// Unlinking a trip that was never linked is a no-op, not an error — the
|
|
// gate is journey ownership, not whether the row existed.
|
|
expect((parseToolResult(await h.client.callTool({
|
|
name: 'remove_journey_trip', arguments: { journeyId: journey.id, tripId: trip.id },
|
|
})) as any).success).toBe(true);
|
|
expect((await h.client.callTool({
|
|
name: 'remove_journey_trip', arguments: { journeyId, tripId: trip.id },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('create_journey_entry refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId, entry_date: '2026-07-01' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry and delete_journey_entry refuse an unknown entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
expect((await h.client.callTool({
|
|
name: 'update_journey_entry', arguments: { entryId: 999999, title: 'X' },
|
|
})).isError).toBe(true);
|
|
expect((await h.client.callTool({
|
|
name: 'delete_journey_entry', arguments: { entryId: 999999 },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('delete_journey_entry removes an owned entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01' },
|
|
})) as any).entry;
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'delete_journey_entry', arguments: { entryId: entry.id },
|
|
})) as any;
|
|
expect(data.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('reorder_journey_entries reorders, and rejects IDs from outside the journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const a = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'A' },
|
|
})) as any).entry;
|
|
const b = (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_entry', arguments: { journeyId: journey.id, entry_date: '2026-07-02', title: 'B' },
|
|
})) as any).entry;
|
|
const reordered = parseToolResult(await h.client.callTool({
|
|
name: 'reorder_journey_entries', arguments: { journeyId: journey.id, orderedIds: [b.id, a.id] },
|
|
})) as any;
|
|
expect(reordered.success).toBe(true);
|
|
expect((await h.client.callTool({
|
|
name: 'reorder_journey_entries', arguments: { journeyId: journey.id, orderedIds: [999999] },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('contributor tools add, re-role and remove; each refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: guest } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
expect((parseToolResult(await h.client.callTool({
|
|
name: 'add_journey_contributor', arguments: { journeyId: journey.id, targetUserId: guest.id, role: 'editor' },
|
|
})) as any).success).toBe(true);
|
|
expect((parseToolResult(await h.client.callTool({
|
|
name: 'update_journey_contributor_role', arguments: { journeyId: journey.id, targetUserId: guest.id, role: 'viewer' },
|
|
})) as any).success).toBe(true);
|
|
expect((parseToolResult(await h.client.callTool({
|
|
name: 'remove_journey_contributor', arguments: { journeyId: journey.id, targetUserId: guest.id },
|
|
})) as any).success).toBe(true);
|
|
|
|
for (const name of ['add_journey_contributor', 'update_journey_contributor_role']) {
|
|
expect((await h.client.callTool({
|
|
name, arguments: { journeyId, targetUserId: guest.id, role: 'editor' },
|
|
})).isError).toBe(true);
|
|
}
|
|
expect((await h.client.callTool({
|
|
name: 'remove_journey_contributor', arguments: { journeyId, targetUserId: guest.id },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('update_journey_preferences refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_journey_preferences', arguments: { journeyId, hide_skeletons: true },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The entry columns the REST route has always accepted. The INSERT writes a
|
|
// place, its coordinates, weather, tags, the verdict, the visibility and the
|
|
// type; the UPDATE allow-list carries all of them plus sort_order. The tools
|
|
// took a location_name at create and nothing at all after that, so an entry
|
|
// written through MCP never landed on the journey map and a wrong place name
|
|
// could not be corrected. Asserted against the row, not the tool's echo.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function entryRow(id: number) {
|
|
return testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(id) as any;
|
|
}
|
|
|
|
async function seedEntry(h: McpHarness, journeyId: number, args: Record<string, unknown> = {}) {
|
|
return (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId, entry_date: '2026-07-01', ...args },
|
|
})) as any).entry;
|
|
}
|
|
|
|
describe('journey entry fields', () => {
|
|
it('create_journey_entry persists the place, coordinates, weather, tags, verdict, visibility and type', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id, {
|
|
title: 'Reykjavik',
|
|
location_name: 'Hallgrimskirkja',
|
|
location_lat: 64.1418,
|
|
location_lng: -21.9266,
|
|
weather: 'overcast, 9C',
|
|
tags: ['church', 'view'],
|
|
pros_cons: { pros: ['the tower'], cons: ['the queue'] },
|
|
visibility: 'public',
|
|
type: 'checkin',
|
|
});
|
|
|
|
const row = entryRow(entry.id);
|
|
expect(row.location_name).toBe('Hallgrimskirkja');
|
|
expect(row.location_lat).toBeCloseTo(64.1418, 4);
|
|
expect(row.location_lng).toBeCloseTo(-21.9266, 4);
|
|
expect(row.weather).toBe('overcast, 9C');
|
|
expect(JSON.parse(row.tags)).toEqual(['church', 'view']);
|
|
expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['the tower'], cons: ['the queue'] });
|
|
expect(row.visibility).toBe('public');
|
|
expect(row.type).toBe('checkin');
|
|
});
|
|
});
|
|
|
|
it('create_journey_entry accepts a one-sided verdict and fills the other side in', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id, { pros_cons: { pros: ['cheap'] } });
|
|
expect(JSON.parse(entryRow(entry.id).pros_cons)).toEqual({ pros: ['cheap'], cons: [] });
|
|
});
|
|
});
|
|
|
|
it('create_journey_entry defaults to a private entry when neither is given', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const row = entryRow((await seedEntry(h, journey.id)).id);
|
|
expect(row.visibility).toBe('private');
|
|
expect(row.type).toBe('entry');
|
|
});
|
|
});
|
|
|
|
it('create_journey_entry refuses coordinates off the globe and an unknown visibility', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
for (const bad of [{ location_lat: 91 }, { location_lng: -181 }, { visibility: 'friends' }]) {
|
|
expect((await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', ...bad },
|
|
})).isError).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry corrects the place and its coordinates', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id, {
|
|
location_name: 'Hallgrimskirja', location_lat: 64.0, location_lng: -21.0,
|
|
});
|
|
|
|
await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: {
|
|
entryId: entry.id,
|
|
location_name: 'Hallgrimskirkja',
|
|
location_lat: 64.1418,
|
|
location_lng: -21.9266,
|
|
},
|
|
});
|
|
|
|
const row = entryRow(entry.id);
|
|
expect(row.location_name).toBe('Hallgrimskirkja');
|
|
expect(row.location_lat).toBeCloseTo(64.1418, 4);
|
|
expect(row.location_lng).toBeCloseTo(-21.9266, 4);
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry sets weather, tags, the verdict, visibility and sort_order', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id);
|
|
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: {
|
|
entryId: entry.id,
|
|
weather: 'rain all day',
|
|
tags: ['museum'],
|
|
pros_cons: { pros: ['free on Sundays'], cons: ['packed'] },
|
|
visibility: 'shared',
|
|
sort_order: 3,
|
|
},
|
|
})) as any;
|
|
|
|
const row = entryRow(entry.id);
|
|
expect(row.weather).toBe('rain all day');
|
|
expect(JSON.parse(row.tags)).toEqual(['museum']);
|
|
expect(JSON.parse(row.pros_cons)).toEqual({ pros: ['free on Sundays'], cons: ['packed'] });
|
|
expect(row.visibility).toBe('shared');
|
|
expect(row.sort_order).toBe(3);
|
|
// The enrichment parses both back out, so the caller sees the objects.
|
|
expect(data.entry.tags).toEqual(['museum']);
|
|
expect(data.entry.pros_cons).toEqual({ pros: ['free on Sundays'], cons: ['packed'] });
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry promotes a trip-derived skeleton to a real entry', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id, { type: 'skeleton', title: 'Blue Lagoon' });
|
|
expect(entryRow(entry.id).type).toBe('skeleton');
|
|
|
|
await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, type: 'entry' } });
|
|
expect(entryRow(entry.id).type).toBe('entry');
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry clears a field on null and leaves the omitted ones alone', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id, {
|
|
title: 'Vik',
|
|
entry_time: '14:30',
|
|
location_name: 'Reynisfjara',
|
|
location_lat: 63.4,
|
|
location_lng: -19.04,
|
|
weather: 'gale',
|
|
tags: ['beach'],
|
|
pros_cons: { pros: ['the stacks'], cons: ['sneaker waves'] },
|
|
});
|
|
|
|
await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: {
|
|
entryId: entry.id,
|
|
entry_time: null,
|
|
location_lat: null,
|
|
location_lng: null,
|
|
weather: null,
|
|
tags: null,
|
|
pros_cons: null,
|
|
},
|
|
});
|
|
|
|
const row = entryRow(entry.id);
|
|
expect(row.entry_time).toBeNull();
|
|
expect(row.location_lat).toBeNull();
|
|
expect(row.location_lng).toBeNull();
|
|
expect(row.weather).toBeNull();
|
|
expect(row.tags).toBeNull();
|
|
expect(row.pros_cons).toBeNull();
|
|
// Untouched: the service only writes the keys it was handed.
|
|
expect(row.title).toBe('Vik');
|
|
expect(row.location_name).toBe('Reynisfjara');
|
|
});
|
|
});
|
|
|
|
/*
|
|
* The stop switch (discussion #2064). The REST route takes it, so the tool
|
|
* does too, and get_journey_stats is where the effect shows.
|
|
*/
|
|
it('update_journey_entry switches a stop out of the figures and back', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const airport = await seedEntry(h, journey.id, {
|
|
title: 'Keflavík', entry_date: '2026-06-01', location_lat: 63.98, location_lng: -22.62,
|
|
});
|
|
await seedEntry(h, journey.id, {
|
|
title: 'Vík', entry_date: '2026-06-03', location_lat: 63.42, location_lng: -19.01,
|
|
});
|
|
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: true },
|
|
})) as any;
|
|
expect(entryRow(airport.id).stats_excluded).toBe(1);
|
|
// The echo carries the boolean, as the REST answer does.
|
|
expect(data.entry.stats_excluded).toBe(true);
|
|
|
|
const off = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId: journey.id, include_route: true },
|
|
})) as any;
|
|
expect(off.stats.points.map((p: any) => p.label)).toEqual(['Vík']);
|
|
expect(off.stats.steps).toBe(1);
|
|
expect(off.stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]);
|
|
|
|
await h.client.callTool({
|
|
name: 'update_journey_entry', arguments: { entryId: airport.id, stats_excluded: false },
|
|
});
|
|
const on = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(entryRow(airport.id).stats_excluded).toBe(0);
|
|
expect(on.stats.steps).toBe(2);
|
|
expect(on.stats.excluded).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry refuses a stats_excluded that is not a boolean', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id);
|
|
expect((await h.client.callTool({
|
|
name: 'update_journey_entry', arguments: { entryId: entry.id, stats_excluded: 'yes' },
|
|
})).isError).toBe(true);
|
|
expect(entryRow(entry.id).stats_excluded).toBe(0);
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry refuses an unknown visibility and an out-of-range longitude', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const entry = await seedEntry(h, journey.id);
|
|
for (const bad of [{ visibility: 'friends' }, { location_lng: 181 }, { type: 'note' }]) {
|
|
expect((await h.client.callTool({
|
|
name: 'update_journey_entry', arguments: { entryId: entry.id, ...bad },
|
|
})).isError).toBe(true);
|
|
}
|
|
// And none of the refusals touched the row.
|
|
const row = entryRow(entry.id);
|
|
expect(row.visibility).toBe('private');
|
|
expect(row.location_lng).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: get_journey_stats', () => {
|
|
async function twoStopJourney(h: McpHarness) {
|
|
const journey = await seedJourney(h);
|
|
await seedEntry(h, journey.id, { title: 'Paris', entry_date: '2026-07-01', location_lat: 48.85, location_lng: 2.35 });
|
|
await seedEntry(h, journey.id, { title: 'Berlin', entry_date: '2026-07-04', location_lat: 52.52, location_lng: 13.4 });
|
|
return journey;
|
|
}
|
|
|
|
it('answers with the distance, days and countries get_journey does not carry', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await twoStopJourney(h);
|
|
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
|
|
// Paris to Berlin is about 880km great-circle, and the figure is metres.
|
|
expect(data.stats.distance).toBeGreaterThan(800_000);
|
|
expect(data.stats.distance).toBeLessThan(950_000);
|
|
expect(data.stats.days).toBe(4);
|
|
expect(data.stats.steps).toBe(2);
|
|
expect(data.stats.countries.map((c: any) => c.code)).toEqual(['FR', 'DE']);
|
|
expect(data.stats.start).toBe('2026-07-01');
|
|
expect(data.stats.end).toBe('2026-07-04');
|
|
|
|
// get_journey still answers with the three counts and nothing more.
|
|
const full = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(Object.keys(full.journey.stats).sort()).toEqual(['entries', 'photos', 'places']);
|
|
});
|
|
});
|
|
|
|
it('leaves the route out unless include_route asks for it', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await twoStopJourney(h);
|
|
|
|
const lean = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(lean.stats.points).toBeUndefined();
|
|
|
|
const full = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId: journey.id, include_route: true },
|
|
})) as any;
|
|
expect(full.stats.points).toHaveLength(2);
|
|
expect(full.stats.points.map((p: any) => p.label)).toEqual(['Paris', 'Berlin']);
|
|
expect(full.stats.points[0].country).toBe('FR');
|
|
});
|
|
});
|
|
|
|
it('refuses a journey the caller cannot see', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
expect((await h.client.callTool({
|
|
name: 'get_journey_stats', arguments: { journeyId },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('journey share-link tools', () => {
|
|
it('creates, reads and revokes the link', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
// No link yet — the tool answers with a null shareLink, not an error.
|
|
const empty = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_share_link', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(empty.shareLink ?? null).toBeNull();
|
|
|
|
const created = parseToolResult(await h.client.callTool({
|
|
name: 'create_journey_share_link', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(created.shareLink).toBeTruthy();
|
|
|
|
const read = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_share_link', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(read.shareLink).toBeTruthy();
|
|
|
|
const revoked = parseToolResult(await h.client.callTool({
|
|
name: 'delete_journey_share_link', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(revoked.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('passes the share flags through, and an omitted flag keeps its value', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
await h.client.callTool({
|
|
name: 'create_journey_share_link',
|
|
arguments: { journeyId: journey.id, share_gallery: false, newest_first: true },
|
|
});
|
|
// Calling it again without the flags must not re-publish the gallery.
|
|
await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId: journey.id } });
|
|
const read = parseToolResult(await h.client.callTool({
|
|
name: 'get_journey_share_link', arguments: { journeyId: journey.id },
|
|
})) as any;
|
|
expect(read.shareLink.share_gallery).toBe(false);
|
|
expect(read.shareLink.newest_first).toBe(true);
|
|
expect(read.shareLink.share_timeline).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('get_journey_share_link is owner-only, like create and delete', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: guest } = createUser(testDb);
|
|
let journeyId = 0;
|
|
await withHarness(owner.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
journeyId = journey.id;
|
|
await h.client.callTool({ name: 'create_journey_share_link', arguments: { journeyId } });
|
|
});
|
|
testDb.prepare(
|
|
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)',
|
|
).run(journeyId, guest.id, 'viewer', Date.now());
|
|
await withHarness(guest.id, async (h) => {
|
|
// The token is the whole credential: a contributor must not be able to
|
|
// read out a link that keeps working after they are removed.
|
|
expect((await h.client.callTool({
|
|
name: 'get_journey_share_link', arguments: { journeyId },
|
|
})).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('every share tool refuses a foreign journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { journeyId } = foreignJourney();
|
|
await withHarness(user.id, async (h) => {
|
|
for (const name of ['get_journey_share_link', 'create_journey_share_link', 'delete_journey_share_link']) {
|
|
expect((await h.client.callTool({ name, arguments: { journeyId } })).isError).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('demo mode', () => {
|
|
// The read tools are deliberately NOT demo-gated, matching the legacy
|
|
// registrar: only the writes and the share-link mutations refuse.
|
|
const WRITES: Array<[string, Record<string, unknown>]> = [
|
|
['create_journey', { title: 'X' }],
|
|
['update_journey', { journeyId: 1, title: 'X' }],
|
|
['delete_journey', { journeyId: 1 }],
|
|
['add_journey_trip', { journeyId: 1, tripId: 1 }],
|
|
['remove_journey_trip', { journeyId: 1, tripId: 1 }],
|
|
['create_journey_entry', { journeyId: 1, entry_date: '2026-07-01' }],
|
|
['update_journey_entry', { entryId: 1, title: 'X' }],
|
|
['delete_journey_entry', { entryId: 1 }],
|
|
['reorder_journey_entries', { journeyId: 1, orderedIds: [1] }],
|
|
['add_journey_contributor', { journeyId: 1, targetUserId: 2, role: 'editor' }],
|
|
['update_journey_contributor_role', { journeyId: 1, targetUserId: 2, role: 'viewer' }],
|
|
['remove_journey_contributor', { journeyId: 1, targetUserId: 2 }],
|
|
['update_journey_preferences', { journeyId: 1, hide_skeletons: true }],
|
|
['create_journey_share_link', { journeyId: 1 }],
|
|
['delete_journey_share_link', { journeyId: 1 }],
|
|
];
|
|
|
|
it.each(WRITES)('%s is blocked for a demo user', async (name, args) => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name, arguments: args });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('list_journeys still answers for a demo user', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(await h.client.callTool({ name: 'list_journeys', arguments: {} })) as any;
|
|
expect(Array.isArray(data.journeys)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Resources (first-time coverage, added when the four journey resources moved
|
|
// from src/mcp/resources.ts onto the nest-mcp registry — same URIs, names and
|
|
// payload shapes as the legacy registrar)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createJourney(h: McpHarness, title = 'Eurotrip'): Promise<any> {
|
|
return (parseToolResult(await h.client.callTool({
|
|
name: 'create_journey', arguments: { title },
|
|
})) as any).journey;
|
|
}
|
|
|
|
describe('Resource: trek://journeys', () => {
|
|
it('returns the journeys of the current user', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await createJourney(h);
|
|
const data = parseResourceResult(await h.client.readResource({ uri: 'trek://journeys' })) as any[];
|
|
expect(Array.isArray(data)).toBe(true);
|
|
expect(data.map((j) => j.id)).toContain(journey.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Resource: trek://journeys/{journeyId}', () => {
|
|
it('returns the hydrated journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await createJourney(h);
|
|
const data = parseResourceResult(await h.client.readResource({ uri: `trek://journeys/${journey.id}` })) as any;
|
|
expect(data.title).toBe('Eurotrip');
|
|
expect(Array.isArray(data.entries)).toBe(true);
|
|
expect(Array.isArray(data.contributors)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('answers the legacy denial payload for a bad id and an inaccessible journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
await withHarness(other.id, async (hOther) => {
|
|
const foreign = await createJourney(hOther, 'Private');
|
|
await withHarness(user.id, async (h) => {
|
|
for (const uri of ['trek://journeys/abc', `trek://journeys/${foreign.id}`]) {
|
|
const data = parseResourceResult(await h.client.readResource({ uri })) as any;
|
|
expect(data).toEqual({ error: 'Trip not found or access denied' });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Resource: trek://journeys/{journeyId}/entries', () => {
|
|
it('returns the enriched entries', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await createJourney(h);
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Day 1', story: 'Arrived' },
|
|
});
|
|
const data = parseResourceResult(await h.client.readResource({ uri: `trek://journeys/${journey.id}/entries` })) as any[];
|
|
expect(data).toHaveLength(1);
|
|
expect(data[0].title).toBe('Day 1');
|
|
expect(Array.isArray(data[0].tags)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Resource: trek://journeys/{journeyId}/contributors', () => {
|
|
it('returns the contributors, with the legacy [] fallback shape', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await createJourney(h);
|
|
const data = parseResourceResult(await h.client.readResource({ uri: `trek://journeys/${journey.id}/contributors` })) as any[];
|
|
expect(Array.isArray(data)).toBe(true);
|
|
expect(data.map((c) => c.user_id ?? c.id)).toContain(user.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Journey resource gating', () => {
|
|
it('does not register the resources when the journey addon is off', async () => {
|
|
const { user } = createUser(testDb);
|
|
setAddonEnabled(testDb, ADDON_IDS.JOURNEY, false);
|
|
await withHarness(user.id, async (h) => {
|
|
await expect(h.client.readResource({ uri: 'trek://journeys' })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it('does not register the resources without the journey:read scope', async () => {
|
|
const { user } = createUser(testDb);
|
|
const h = await createMcpHarness({ userId: user.id, scopes: ['trips:read'] });
|
|
try {
|
|
await expect(h.client.readResource({ uri: 'trek://journeys' })).rejects.toThrow();
|
|
} finally {
|
|
await h.cleanup();
|
|
}
|
|
});
|
|
});
|