mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
8a2cb10f7b
A Dawarich stay's row read fields one side of a union did not have, the photo cards narrowed an entry's day through an optional chain, and the settings service cast a journey row straight to a record. Helpers only their own file reads are no longer exported, and the photo tests seed their rows with insertRow like the other journey suites.
1543 lines
57 KiB
TypeScript
1543 lines
57 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 { JourneyEntryPhotos } from '../../../src/db/entities/JourneyEntryPhotos.entity';
|
|
import { JourneyPhotos } from '../../../src/db/entities/JourneyPhotos.entity';
|
|
import { Journeys } from '../../../src/db/entities/Journeys.entity';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { addJourneyContributor } from '../../helpers/factories/journeys';
|
|
import { createRow, findRow, insertRow } 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('MCP-JRN-ENTRY-001: list_journey_entries keeps the days from/to name and names its journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h, 'Berlin 2026');
|
|
const days = [
|
|
['2026-10-20', 'Before'],
|
|
['2026-10-21', 'Yesterday'],
|
|
['2026-10-22', 'Today'],
|
|
] as const;
|
|
// One after the other, in this order.
|
|
await days.reduce(async (previous, [entry_date, title]) => {
|
|
await previous;
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date, title },
|
|
});
|
|
}, Promise.resolve());
|
|
const read = async (range: Record<string, string>) =>
|
|
parseToolResult(
|
|
await h.client.callTool({ name: 'list_journey_entries', arguments: { journeyId: journey.id, ...range } }),
|
|
) as { entries: Array<{ title: string }>; journey: { id: number; title: string } };
|
|
|
|
const day = await read({ from: '2026-10-21', to: '2026-10-21' });
|
|
expect(day.entries.map((e) => e.title)).toEqual(['Yesterday']);
|
|
expect(day.journey).toEqual({ id: journey.id, title: 'Berlin 2026' });
|
|
expect((await read({ from: '2026-10-21' })).entries.map((e) => e.title)).toEqual(['Yesterday', 'Today']);
|
|
expect((await read({ to: '2026-10-20' })).entries.map((e) => e.title)).toEqual(['Before']);
|
|
expect((await read({})).entries).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
it('MCP-JRN-ENTRY-002: list_journey_entries refuses a day that is no calendar day', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const result = await h.client.callTool({
|
|
name: 'list_journey_entries',
|
|
arguments: { journeyId: journey.id, from: '2026-02-30' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('MCP-JRN-ENTRY-003: get_journey_entry answers one entry with its photos and its journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h, 'Japan');
|
|
const created = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Arrival', story: '**Tokyo**' },
|
|
}),
|
|
) as { entry: { id: number } };
|
|
const data = parseToolResult(
|
|
await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId: created.entry.id } }),
|
|
) as { entry: { id: number; title: string; photos: unknown[] }; journey: { id: number; title: string } };
|
|
expect(data.entry.id).toBe(created.entry.id);
|
|
expect(data.entry.title).toBe('Arrival');
|
|
expect(data.entry.photos).toEqual([]);
|
|
expect(data.journey).toEqual({ id: journey.id, title: 'Japan' });
|
|
});
|
|
});
|
|
|
|
it('MCP-JRN-ENTRY-004: get_journey_entry does not show an entry of a journal the caller cannot see', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
let entryId = 0;
|
|
await withHarness(other.id, async (h) => {
|
|
const journey = await seedJourney(h, 'Private');
|
|
entryId = (
|
|
parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_journey_entry',
|
|
arguments: { journeyId: journey.id, entry_date: '2026-07-01', title: 'Mine' },
|
|
}),
|
|
) as { entry: { id: number } }
|
|
).entry.id;
|
|
});
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId } });
|
|
expect(result.isError).toBe(true);
|
|
const missing = await h.client.callTool({ name: 'get_journey_entry', arguments: { entryId: entryId + 1000 } });
|
|
expect(missing.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('update_journey switches the preparation of recorded days on and refuses a value that is no boolean', async () => {
|
|
const { user } = createUser(testDb);
|
|
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, auto_prepare_recordings: true },
|
|
}),
|
|
) as { journey: { auto_prepare_recordings: number } };
|
|
expect(updated.journey.auto_prepare_recordings).toBe(1);
|
|
const refused = await h.client.callTool({
|
|
name: 'update_journey',
|
|
arguments: { journeyId: journey.id, auto_prepare_recordings: 'yes' },
|
|
});
|
|
expect(refused.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('update_journey answers what the changed settings held before, flags as booleans, and only those', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h, 'Before');
|
|
const updated = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'update_journey',
|
|
arguments: { journeyId: journey.id, title: 'After', show_mood: false, status_override: 'live' },
|
|
}),
|
|
) as { journey: { title: string; show_mood: number }; previous: Record<string, unknown> };
|
|
expect(updated.journey).toMatchObject({ title: 'After', show_mood: 0 });
|
|
expect(updated.previous).toEqual({ title: 'Before', show_mood: true, status_override: null });
|
|
// Nothing to change answers the journey as it is, with nothing replaced.
|
|
const same = parseToolResult(
|
|
await h.client.callTool({ name: 'update_journey', arguments: { journeyId: journey.id } }),
|
|
) as { previous: Record<string, unknown> };
|
|
expect(same.previous).toEqual({});
|
|
});
|
|
});
|
|
|
|
it('update_journey takes the trip tracks and the cover gradient the REST route takes, and clears a subtitle', async () => {
|
|
const { user } = createUser(testDb);
|
|
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,
|
|
show_trip_tracks: true,
|
|
cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)',
|
|
subtitle: null,
|
|
},
|
|
}),
|
|
) as { journey: { show_trip_tracks: number; cover_gradient: string; subtitle: string | null } };
|
|
expect(updated.journey).toMatchObject({
|
|
show_trip_tracks: 1,
|
|
cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)',
|
|
subtitle: null,
|
|
});
|
|
const refused = await h.client.callTool({
|
|
name: 'update_journey',
|
|
arguments: { journeyId: journey.id, show_trip_tracks: 'yes' },
|
|
});
|
|
expect(refused.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);
|
|
// The ids as they stood before, in the journal's reading order, for a way back.
|
|
expect(reordered.previous_order).toEqual([a.id, b.id]);
|
|
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;
|
|
}
|
|
|
|
let assetSeq = 0;
|
|
|
|
/** A photo in the journey's gallery, and on the entry when one is named; answers its journey photo id. */
|
|
async function seedGalleryPhoto(journeyId: number, entryId?: number): Promise<number> {
|
|
assetSeq += 1;
|
|
const trekId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: `asset-${assetSeq}` });
|
|
const galleryId = await insertRow(orm, JourneyPhotos, {
|
|
journey: journeyId,
|
|
photo: trekId,
|
|
caption: null,
|
|
shared: 0,
|
|
sort_order: 0,
|
|
created_at: Date.now(),
|
|
});
|
|
if (entryId !== undefined) {
|
|
await insertRow(orm, JourneyEntryPhotos, {
|
|
entry: entryId,
|
|
journeyPhoto: galleryId,
|
|
sort_order: 0,
|
|
created_at: Date.now(),
|
|
});
|
|
}
|
|
return galleryId;
|
|
}
|
|
|
|
describe('journey photo tools', () => {
|
|
it('link_journey_photo and unlink_journey_photo put a gallery photo on an entry and take it off, telling the tabs', 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' });
|
|
const photoId = await seedGalleryPhoto(journey.id);
|
|
broadcastMock.mockClear();
|
|
const linked = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'link_journey_photo',
|
|
arguments: { entryId: entry.id, journey_photo_id: photoId },
|
|
}),
|
|
) as { photo: { id: number }; entryId: number };
|
|
expect(linked).toMatchObject({ photo: { id: photoId }, entryId: entry.id });
|
|
expect(broadcastMock).toHaveBeenCalledWith(
|
|
user.id,
|
|
expect.objectContaining({ type: 'journey:entry:updated' }),
|
|
undefined,
|
|
);
|
|
const unlinked = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'unlink_journey_photo',
|
|
arguments: { entryId: entry.id, journey_photo_id: photoId },
|
|
}),
|
|
);
|
|
expect(unlinked).toEqual({ success: true, entryId: entry.id, journey_photo_id: photoId });
|
|
const again = await h.client.callTool({
|
|
name: 'unlink_journey_photo',
|
|
arguments: { entryId: entry.id, journey_photo_id: photoId },
|
|
});
|
|
expect(again.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('move_journey_photos moves photos between entries of a journey and refuses one of another journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const from = await seedEntry(h, journey.id, { title: 'Vik' });
|
|
const to = await seedEntry(h, journey.id, { title: 'Reynisfjara', entry_date: '2026-07-02' });
|
|
const photoId = await seedGalleryPhoto(journey.id, from.id);
|
|
const moved = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'move_journey_photos',
|
|
arguments: { fromEntryId: from.id, toEntryId: to.id, photoIds: [photoId] },
|
|
}),
|
|
);
|
|
expect(moved).toEqual({ moved: [photoId], fromEntryId: from.id, toEntryId: to.id, kept: false });
|
|
const other = await seedJourney(h, 'Other');
|
|
const elsewhere = await seedEntry(h, other.id);
|
|
const refused = await h.client.callTool({
|
|
name: 'move_journey_photos',
|
|
arguments: { fromEntryId: to.id, toEntryId: elsewhere.id, photoIds: [photoId] },
|
|
});
|
|
expect(refused.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('set_journey_photo_caption answers the caption it replaced and refuses a photo of another journey', async () => {
|
|
const { user } = createUser(testDb);
|
|
await withHarness(user.id, async (h) => {
|
|
const journey = await seedJourney(h);
|
|
const other = await seedJourney(h, 'Other');
|
|
const photoId = await seedGalleryPhoto(journey.id);
|
|
const first = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'set_journey_photo_caption',
|
|
arguments: { journeyId: journey.id, journey_photo_id: photoId, caption: 'Black sand' },
|
|
}),
|
|
) as { photo: { caption: string }; previous: { caption: string | null } };
|
|
expect(first).toMatchObject({ photo: { caption: 'Black sand' }, previous: { caption: null } });
|
|
const refused = await h.client.callTool({
|
|
name: 'set_journey_photo_caption',
|
|
arguments: { journeyId: other.id, journey_photo_id: photoId, caption: 'x' },
|
|
});
|
|
expect(refused.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
it('update_journey_entry answers what the named fields held before, decoded, and nothing for the rest', 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',
|
|
tags: ['beach'],
|
|
pros_cons: { pros: ['the stacks'], cons: [] },
|
|
is_draft: true,
|
|
});
|
|
const updated = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'update_journey_entry',
|
|
arguments: { entryId: entry.id, title: 'Reynisfjara', tags: null, is_draft: false, location_lat: 63.4 },
|
|
}),
|
|
) as { entry: { title: string }; previous: Record<string, unknown> };
|
|
expect(updated.entry.title).toBe('Reynisfjara');
|
|
expect(updated.previous).toEqual({ title: 'Vik', tags: ['beach'], is_draft: true, location_lat: null });
|
|
// Written back through the same tool, the entry reads as it did.
|
|
await h.client.callTool({ name: 'update_journey_entry', arguments: { entryId: entry.id, ...updated.previous } });
|
|
const row = await entryRow(entry.id);
|
|
expect(row.title).toBe('Vik');
|
|
expect(row.tags).toBe('["beach"]');
|
|
expect(row.is_draft).toBe(1);
|
|
});
|
|
});
|
|
|
|
/*
|
|
* 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();
|
|
}
|
|
});
|
|
});
|