mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
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.
1252 lines
45 KiB
TypeScript
1252 lines
45 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 { ADDON_IDS } from '../../../src/addons';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
|
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { addJourneyContributor } from '../../helpers/factories/journeys';
|
|
import { createRow, findRow } from '../../helpers/factories/rows';
|
|
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { 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());
|
|
});
|
|
|
|
/*
|
|
* 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;
|
|
},
|
|
}));
|
|
|
|
const realtime = new FakeRealtimeService();
|
|
const broadcastMock = realtime.broadcastMock;
|
|
// The suite asserts both channels on one recorder, as its module mock did.
|
|
realtime.broadcastToUserMock.mockImplementation((...args) =>
|
|
broadcastMock(...(args as unknown as Parameters<typeof broadcastMock>)),
|
|
);
|
|
|
|
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;
|
|
});
|
|
|
|
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>) {
|
|
const h = await createMcpHarness({ realtime, 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);
|
|
});
|
|
});
|
|
|
|
it('creates a draft and publishes it, as the REST route does (#696)', 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 draft = (
|
|
parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Rough', is_draft: true },
|
|
}),
|
|
) as any
|
|
).entry;
|
|
expect(draft.is_draft).toBe(true);
|
|
const published = (
|
|
parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: { entryId: draft.id, is_draft: false },
|
|
}),
|
|
) as any
|
|
).entry;
|
|
expect(published.is_draft).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
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. */
|
|
async function foreignJourney() {
|
|
const { user: other } = createUser(testDb);
|
|
const j = await createRow(orm, Journeys, {
|
|
user: other.id,
|
|
title: 'Not yours',
|
|
status: 'draft',
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
});
|
|
return { otherId: other.id, journeyId: j.id };
|
|
}
|
|
|
|
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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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 } = await 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('reorder_journey_entry_photos refuses ids that are not the entry photos (#824)', 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', title: 'A' },
|
|
}),
|
|
) as any
|
|
).entry;
|
|
expect(
|
|
(
|
|
await h.client.callTool({
|
|
name: 'reorder_journey_entry_photos',
|
|
arguments: { entryId: entry.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 } = await 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 } = await 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function entryRow(id: number) {
|
|
const row = await findRow(orm, JourneyEntries, { id });
|
|
if (!row) throw new Error(`no journey entry ${id}`);
|
|
return row;
|
|
}
|
|
|
|
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 = await 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(String(row.tags))).toEqual(['church', 'view']);
|
|
expect(JSON.parse(String(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(String((await 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 = await 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 = await 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 = await entryRow(entry.id);
|
|
expect(row.weather).toBe('rain all day');
|
|
expect(JSON.parse(String(row.tags))).toEqual(['museum']);
|
|
expect(JSON.parse(String(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((await entryRow(entry.id)).type).toBe('skeleton');
|
|
|
|
await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, type: 'entry' } });
|
|
expect((await 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 = await 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((await 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((await 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((await 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 = await 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 } = await 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 } });
|
|
});
|
|
await addJourneyContributor(orm, journeyId, guest.id, 'viewer');
|
|
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 } = await 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({ realtime, userId: user.id, scopes: ['trips:read'] });
|
|
try {
|
|
await expect(h.client.readResource({ uri: 'trek://journeys' })).rejects.toThrow();
|
|
} finally {
|
|
await h.cleanup();
|
|
}
|
|
});
|
|
});
|