mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
414 lines
17 KiB
TypeScript
414 lines
17 KiB
TypeScript
/**
|
|
* Tours e2e for a hike planned with an assistant over MCP: proposed, created,
|
|
* put on a day, pointed at the packing list, exported as GPX and changed,
|
|
* every tool called through a real MCP session on the whole app (buildApp,
|
|
* the real registry with its addon gates, scopes and error mapper, the real
|
|
* services and a migrated temp SQLite). The way is planned by the road
|
|
* trip's router with its paced queue; only the routing host behind
|
|
* safeFetchAdminConfigured is a stub answering /route, /height and
|
|
* /trace_attributes as Valhalla does, and the trail grades, the places along
|
|
* the way and the day's weather are spies on the container's
|
|
* TourTrailGradeService, TourPoiSearchService and WeatherService, so nothing
|
|
* leaves the process. The
|
|
* router's pacing runs on the test's clock during a call, moved on until the
|
|
* call is done, so the suite does not wait it out. The broadcasts are spied
|
|
* on the container's RealtimeService.
|
|
*/
|
|
import { buildApp } from '../../src/bootstrap';
|
|
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { Tours } from '../../src/db/entities/Tours.entity';
|
|
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
|
import { unchangedSinceApplied } from '../../src/nest/assistant/assistant-undo.helpers';
|
|
import { withRequestContext } from '../../src/nest/database/request-context';
|
|
import { TourPoiSearchService } from '../../src/nest/tours/tour-poi-search.service';
|
|
import { TourTrailGradeService } from '../../src/nest/tours/tour-trail-grade.service';
|
|
import { WeatherService } from '../../src/nest/weather/weather.service';
|
|
import { safeFetchAdminConfigured } from '../../src/utils/ssrfGuard';
|
|
import { countRows, findRow } from '../helpers/factories/rows';
|
|
import { setAddonEnabled } from '../helpers/factories/settings';
|
|
import { makeDay, makeTrip } from '../helpers/factories/trips';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
|
|
import { valhallaRoute } from '../helpers/valhalla';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { hikeProposalSchema, type TourTrailGrade } from '@trek/shared';
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
|
safeFetchAdminConfigured: vi.fn(),
|
|
}));
|
|
|
|
let app: INestApplication;
|
|
let orm: MikroORM;
|
|
let broadcasts: RealtimeSpies;
|
|
|
|
/** A point `k` steps of 0.0005° north of 47.6° N on 11.3° E, about 56 m a step. */
|
|
const at = (k: number): [number, number] => [47.6 + k / 2000, 11.3];
|
|
const stepsOf = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => at(from + index));
|
|
/** The height under a point: 40 m a step up. */
|
|
const heightAt = (lat: number) => Math.round(800 + (lat - 47.6) * 80_000);
|
|
|
|
const WAYPOINTS = [
|
|
{ lat: at(0)[0], lng: 11.3, name: 'Car park' },
|
|
{ lat: at(10)[0], lng: 11.3, name: 'Hut' },
|
|
{ lat: at(20)[0], lng: 11.3, name: 'Summit' },
|
|
];
|
|
|
|
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
|
|
|
/** The routing host: two legs up to the summit, the height of every point, two ways under the line. */
|
|
function valhalla() {
|
|
vi.mocked(safeFetchAdminConfigured).mockImplementation(async (url, init) => {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as { shape?: Array<{ lat: number; lon: number }> };
|
|
if (url.endsWith('/route')) {
|
|
return json(
|
|
valhallaRoute([
|
|
{ line: stepsOf(0, 10), km: 0.556, seconds: 600 },
|
|
{ line: stepsOf(10, 20), km: 0.556, seconds: 720 },
|
|
]),
|
|
);
|
|
}
|
|
if (url.endsWith('/height')) {
|
|
return json({ range_height: (body.shape ?? []).map((point, index) => [index, heightAt(point.lat)]) });
|
|
}
|
|
if (url.endsWith('/trace_attributes')) {
|
|
return json({
|
|
edges: [
|
|
{ way_id: 501, length: 0.556 },
|
|
{ way_id: 502, length: 0.556 },
|
|
],
|
|
});
|
|
}
|
|
return json({}, 404);
|
|
});
|
|
}
|
|
|
|
const grade = (id: number, sac: TourTrailGrade['sac_scale']): TourTrailGrade => ({
|
|
id,
|
|
mtb_scale: null,
|
|
mtb_scale_uphill: null,
|
|
mtb_scale_imba: null,
|
|
sac_scale: sac,
|
|
sac_scale_unclear: null,
|
|
surface: null,
|
|
highway: 'path',
|
|
});
|
|
|
|
/** What the routing host was asked since the counts were last taken, by path. */
|
|
let askedBefore = 0;
|
|
function askedSince(): string[] {
|
|
const calls = vi.mocked(safeFetchAdminConfigured).mock.calls.slice(askedBefore);
|
|
askedBefore += calls.length;
|
|
return calls.map(([url]) => new URL(String(url)).pathname);
|
|
}
|
|
|
|
/** The test's clock moved on a tenth of a second at a time, each with a turn of the real event loop, until `done`. */
|
|
async function moveOn(done: () => boolean): Promise<void> {
|
|
if (done()) return;
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
await moveOn(done);
|
|
}
|
|
|
|
/**
|
|
* A call moved through to its end on the test's clock: the router waits about
|
|
* a second between two questions to the host, here no time at all.
|
|
*/
|
|
async function settled<T>(pending: Promise<T>): Promise<T> {
|
|
let done = false;
|
|
const outcome = pending.then(
|
|
(value) => {
|
|
done = true;
|
|
return { value };
|
|
},
|
|
(error: unknown) => {
|
|
done = true;
|
|
return { error };
|
|
},
|
|
);
|
|
await moveOn(() => done);
|
|
const result = await outcome;
|
|
if ('error' in result) throw result.error;
|
|
return result.value;
|
|
}
|
|
|
|
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
|
|
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
try {
|
|
return await settled(callOnce(userId, name, args));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
}
|
|
|
|
async function callOnce(userId: number, name: string, args: Record<string, unknown>) {
|
|
const server = createLocalMcpServer('TREK hike e2e');
|
|
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
|
await withRequestContext(orm, () =>
|
|
app
|
|
.get(McpRegistryService)
|
|
.attach(
|
|
server,
|
|
{ userId, scopes: null, isStaticToken: false },
|
|
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
|
|
),
|
|
);
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
const client = new Client({ name: 'hike-e2e', version: '1.0.0' });
|
|
await server.connect(serverTransport);
|
|
await client.connect(clientTransport);
|
|
try {
|
|
const result = await client.callTool({ name, arguments: args });
|
|
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
|
const text = content[0]?.text ?? '';
|
|
return result.isError === true ? { error: text } : JSON.parse(text);
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
}
|
|
|
|
/** The trip sends as `[event, excluded socket]`, emptied after each read. */
|
|
function sent(): Array<[string, unknown]> {
|
|
const calls = broadcasts.broadcast.mock.calls.map((call): [string, unknown] => [String(call[1]), call[3]]);
|
|
broadcasts.broadcast.mockClear();
|
|
return calls;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp();
|
|
orm = app.get(MikroORM);
|
|
broadcasts = spyOnRealtime(app);
|
|
await setAddonEnabled(orm, 'tours', true);
|
|
await setAddonEnabled(orm, 'packing', true);
|
|
vi.spyOn(app.get(TourTrailGradeService), 'grades').mockResolvedValue({
|
|
ways: [grade(501, 'hiking'), grade(502, 'mountain_hiking')],
|
|
unanswered: [],
|
|
});
|
|
vi.spyOn(app.get(TourPoiSearchService), 'search').mockResolvedValue({
|
|
pois: [
|
|
{
|
|
id: 'node:1',
|
|
name: 'Hut',
|
|
lat: at(10)[0],
|
|
lng: 11.3001,
|
|
category: 'shelter',
|
|
kind: 'amenity=shelter',
|
|
source: 'openstreetmap',
|
|
address: null,
|
|
website: null,
|
|
phone: null,
|
|
opening_hours: null,
|
|
distance_m: 8,
|
|
along_m: 556,
|
|
},
|
|
],
|
|
truncated: false,
|
|
partial: false,
|
|
capped: false,
|
|
unavailable: [],
|
|
});
|
|
vi.spyOn(app.get(WeatherService), 'getDetailed').mockResolvedValue({
|
|
type: 'forecast',
|
|
temp: 14,
|
|
main: 'Clear',
|
|
description: '',
|
|
sunrise: '05:30',
|
|
sunset: '21:00',
|
|
precipitation_probability_max: 70,
|
|
hourly: Array.from({ length: 24 }, (_, hour) => ({
|
|
hour,
|
|
temp: 12,
|
|
precipitation: 0,
|
|
precipitation_probability: hour === 9 ? 70 : 5,
|
|
main: 'Clear',
|
|
wind: 10,
|
|
humidity: 50,
|
|
})),
|
|
});
|
|
valhalla();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
});
|
|
|
|
describe('a hike planned with an assistant over MCP', () => {
|
|
it('is proposed, created, put on a day, packed for, exported and changed, the whole trip told of each write', async () => {
|
|
const { user } = await makeUser(orm);
|
|
const trip = await makeTrip(orm, user.id, { title: 'Alps' });
|
|
const day = await makeDay(orm, trip.id, { day_number: 1, date: '2027-07-10' });
|
|
|
|
// The proposal: the way, its heights and grades, and nothing written.
|
|
const proposal = await callTool(user.id, 'propose_hike', {
|
|
tripId: trip.id,
|
|
name: 'Up to the summit',
|
|
waypoints: WAYPOINTS,
|
|
max_hiking_difficulty: 2,
|
|
variant_label: 'by the hut',
|
|
day_id: day.id,
|
|
start_time: '08:30',
|
|
include_geometry: true,
|
|
});
|
|
expect(hikeProposalSchema.parse(proposal)).toEqual(proposal);
|
|
expect(proposal).toMatchObject({
|
|
route: { planned: true },
|
|
variant_label: 'by the hut',
|
|
distance_m: 1112,
|
|
ascent_m: 800,
|
|
heights_known: true,
|
|
min_ele_m: 800,
|
|
max_ele_m: 1600,
|
|
duration_basis: 'din33466',
|
|
sac: { highest: 2, unknown_share: 0 },
|
|
high_point: { lat: at(20)[0], lng: 11.3, ele_m: 1600, at_m: 1112 },
|
|
day_id: day.id,
|
|
date: '2027-07-10',
|
|
start_time: '08:30',
|
|
schedule: { date: '2027-07-10', day_id: day.id, start: '08:30', weather: 'forecast', sunset: '21:00' },
|
|
warnings: [{ kind: 'rain', at: '09:00' }],
|
|
});
|
|
expect(proposal.waypoints.map((point: { at_m: number }) => point.at_m)).toEqual([0, 556, 1112]);
|
|
expect(proposal.waypoints.map((point: { arrival: string }) => point.arrival)[0]).toBe('08:30');
|
|
expect(proposal.along).toEqual([
|
|
{ name: 'Hut', category: 'shelter', lat: at(10)[0], lng: 11.3001, at_m: 556, off_m: 8 },
|
|
]);
|
|
expect(proposal.line_geometry).toHaveLength(21);
|
|
// The highest point, asked to about a kilometre.
|
|
expect(app.get(WeatherService).getDetailed).toHaveBeenCalledWith('47.61', '11.3', '2027-07-10', 'en');
|
|
expect(await countRows(orm, Tours, {})).toBe(0);
|
|
expect(sent()).toEqual([]);
|
|
expect(askedSince()).toEqual(['/route', '/height', '/trace_attributes']);
|
|
|
|
// The hike the traveller said yes to, with its whole line and its heights.
|
|
const created = await callTool(user.id, 'create_hike', {
|
|
tripId: trip.id,
|
|
name: 'Up to the summit',
|
|
waypoints: WAYPOINTS,
|
|
max_hiking_difficulty: 2,
|
|
});
|
|
expect(created.route).toEqual({ planned: true });
|
|
expect(created.tour).toMatchObject({
|
|
name: 'Up to the summit',
|
|
tour_type: 'hike',
|
|
max_hiking_difficulty: 2,
|
|
elevation_gain: 800,
|
|
duration: proposal.duration_minutes,
|
|
revision: 1,
|
|
route_pending: false,
|
|
});
|
|
const placeId: number = created.tour.place_id;
|
|
const line = JSON.parse(String((await findRow(orm, Places, { id: placeId }))?.route_geometry)) as number[][];
|
|
expect(line).toHaveLength(21);
|
|
expect(line.every((point) => point.length === 3)).toBe(true);
|
|
expect(sent()).toEqual([
|
|
['tours:changed', undefined],
|
|
['place:created', undefined],
|
|
]);
|
|
// The way and its heights as the proposal had them, and no grades: the host is not asked. The hike memo
|
|
// (fifteen minutes) and the router's own caches both answer here; HIKE-ROUTE-021 tells the memo apart.
|
|
expect(askedSince()).toEqual([]);
|
|
// Just made, the hike can be taken back: the trip as the undo reads it has it as it was created.
|
|
const summary = await callTool(user.id, 'get_trip_summary', { tripId: trip.id });
|
|
const places = await callTool(user.id, 'list_places', { tripId: trip.id });
|
|
expect(unchangedSinceApplied('create_hike', created, { summary, places })).toBe(true);
|
|
|
|
// On the day, pointed at the packing list, exported with its heights.
|
|
const assigned = await callTool(user.id, 'assign_place_to_day', { tripId: trip.id, dayId: day.id, placeId });
|
|
expect(assigned.assignment).toBeTruthy();
|
|
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
|
|
sent();
|
|
const packed = await callTool(user.id, 'link_tour_packing', { tripId: trip.id, placeId, category: null });
|
|
expect(packed.packing_link).toMatchObject({ category: null });
|
|
expect(sent().map(([event]) => event)).toEqual(['tours:changed']);
|
|
const exported = await callTool(user.id, 'export_tour_gpx', { tripId: trip.id, placeId });
|
|
expect(exported.gpx).toContain('Up to the summit');
|
|
expect(exported.gpx).toContain('<ele>');
|
|
|
|
// A harder limit plans the way anew; the hike stays on its day.
|
|
const changed = await callTool(user.id, 'update_tour', {
|
|
tripId: trip.id,
|
|
placeId,
|
|
expected_revision: 1,
|
|
max_hiking_difficulty: 3,
|
|
name: 'Summit by the ridge',
|
|
});
|
|
expect(changed).toMatchObject({ replanned: true, route: { planned: true } });
|
|
expect(changed.tour).toMatchObject({ name: 'Summit by the ridge', max_hiking_difficulty: 3, revision: 2 });
|
|
expect(sent()).toEqual([
|
|
['tours:changed', undefined],
|
|
['place:updated', undefined],
|
|
]);
|
|
expect(await countRows(orm, DayAssignments, { place: placeId })).toBe(1);
|
|
// The way under the new limit; its heights are the same line's, and a change reads no grades.
|
|
expect(askedSince()).toEqual(['/route']);
|
|
|
|
// A change based on the old revision is refused and writes nothing.
|
|
const stale = await callTool(user.id, 'update_tour', {
|
|
tripId: trip.id,
|
|
placeId,
|
|
expected_revision: 1,
|
|
name: 'Stale',
|
|
});
|
|
expect(stale).toEqual({ error: 'This tour was changed in the meantime' });
|
|
expect((await findRow(orm, Places, { id: placeId }))?.name).toBe('Summit by the ridge');
|
|
expect(sent()).toEqual([]);
|
|
expect(askedSince()).toEqual([]);
|
|
});
|
|
|
|
it('creates a hike straight onto a day in one write, its start kept, and tells the trip of the tour and the visit', async () => {
|
|
const { user } = await makeUser(orm);
|
|
const trip = await makeTrip(orm, user.id, { title: 'Alps' });
|
|
const day = await makeDay(orm, trip.id, { day_number: 1, date: '2027-07-10' });
|
|
sent();
|
|
const created = await callTool(user.id, 'create_hike', {
|
|
tripId: trip.id,
|
|
name: 'Up to the summit',
|
|
waypoints: WAYPOINTS,
|
|
day_id: day.id,
|
|
start_time: '08:30',
|
|
});
|
|
expect(created.route).toEqual({ planned: true });
|
|
expect(created.assignment).toEqual({ id: expect.any(Number), day_id: day.id });
|
|
const visit = await findRow(orm, DayAssignments, { id: created.assignment.id });
|
|
expect(visit).toMatchObject({ place_id: created.tour.place_id, assignment_time: '08:30' });
|
|
expect(sent()).toEqual([
|
|
['tours:changed', undefined],
|
|
['place:created', undefined],
|
|
['assignment:created', undefined],
|
|
]);
|
|
askedSince();
|
|
});
|
|
|
|
it('takes a GPX text in as a hike, and refuses the tools to someone outside the trip', async () => {
|
|
const { user } = await makeUser(orm);
|
|
const { user: outsider } = await makeUser(orm);
|
|
const trip = await makeTrip(orm, user.id);
|
|
const gpx =
|
|
'<gpx><trk><name>Ridge</name><trkseg><trkpt lat="47.6" lon="11.3"><ele>800</ele></trkpt>' +
|
|
'<trkpt lat="47.61" lon="11.3"><ele>1000</ele></trkpt></trkseg></trk></gpx>';
|
|
const imported = await callTool(user.id, 'import_tour_gpx', { tripId: trip.id, gpx, file_name: 'ridge.gpx' });
|
|
expect(imported).toMatchObject({ caution: false, skipped: 0 });
|
|
expect(imported.tours[0]).toMatchObject({ name: 'Ridge', tour_type: 'hike', elevation_gain: 200 });
|
|
expect(sent()).toEqual([
|
|
['tours:changed', undefined],
|
|
['place:created', undefined],
|
|
]);
|
|
|
|
const refused = { error: 'Trip not found or access denied.' };
|
|
expect(
|
|
await callTool(outsider.id, 'propose_hike', { tripId: trip.id, name: 'Ridge', waypoints: WAYPOINTS }),
|
|
).toEqual(refused);
|
|
expect(await callTool(outsider.id, 'import_tour_gpx', { tripId: trip.id, gpx })).toEqual(refused);
|
|
expect(await callTool(user.id, 'import_tour_gpx', { tripId: trip.id, gpx, tour_type: 'kayak' })).toMatchObject({
|
|
error: expect.stringContaining('a paddle import needs boat_type'),
|
|
});
|
|
});
|
|
});
|