mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
/**
|
|
* TourChangeMcp (MCP-T02): a change to a saved city walk prepared for the
|
|
* traveller to review. It reads the saved walk, the visits of all its days
|
|
* and the places the new stations name, plans the way on foot before and
|
|
* after with the road trip's router (a stub here), and writes nothing. The
|
|
* suite takes its subject from the container over a snapshot test db and
|
|
* seeds through the ORM factories.
|
|
*/
|
|
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
|
|
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
|
import { Tours } from '../../../src/db/entities/Tours.entity';
|
|
import type { McpContext } from '../../../src/nest-mcp';
|
|
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
|
import { catchDomainError, DomainError } from '../../../src/nest/common/domain-error';
|
|
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
|
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
|
import { CityWalkFootWayService } from '../../../src/nest/tours/city-walk-foot-way.service';
|
|
import { TOUR_CHANGE_REFUSED, TourChangePreviewService } from '../../../src/nest/tours/tour-change-preview.service';
|
|
import { TourChangeMcp } from '../../../src/nest/tours/tour-change.mcp';
|
|
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
|
import { makePlace } from '../../helpers/factories/places';
|
|
import { countRows, findRow } from '../../helpers/factories/rows';
|
|
import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
|
|
import { makeDay, makeTrip } from '../../helpers/factories/trips';
|
|
import { makeUser } from '../../helpers/factories/users';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import type { TourChange } from '@trek/shared';
|
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const db = createSnapshotTestDb();
|
|
const route = vi.fn();
|
|
let t: TestModule;
|
|
let mcp: TourChangeMcp;
|
|
const orm = () => t.get(MikroORM);
|
|
|
|
beforeAll(async () => {
|
|
t = await createTestModule({
|
|
db,
|
|
imports: [ToursModule],
|
|
overrides: [{ provide: RoadtripRouterService, useValue: { route } }],
|
|
});
|
|
mcp = t.get(TourChangeMcp);
|
|
});
|
|
beforeEach(() => {
|
|
resetTestDb(db);
|
|
// Every leg the router plans is 500 m and 400 s on foot.
|
|
route
|
|
.mockReset()
|
|
.mockImplementation(
|
|
async (_userId: number, _tripId: number, _dayId: number, points: Array<{ lat: number; lng: number }>) => ({
|
|
leg: { line: points.map((point): [number, number] => [point.lat, point.lng]) },
|
|
parts: points.slice(1).map(() => ({ distance: 500, duration: 400 })),
|
|
snapped: points.map(() => ({ meters: 4 })),
|
|
avoidMissed: [],
|
|
}),
|
|
);
|
|
});
|
|
afterAll(async () => {
|
|
await t.close();
|
|
db.close();
|
|
});
|
|
|
|
const ctx = (userId: number) => ({ userId }) as McpContext;
|
|
|
|
/** The JSON a tool answered, or the error text it refused with. */
|
|
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
|
const text = result.content[0]?.text ?? '';
|
|
return result.isError ? { error: text } : JSON.parse(text);
|
|
}
|
|
|
|
/**
|
|
* A city walk at revision 3 from the gate past the protected cathedral and
|
|
* the market to the park (a must), on day 1 where the gate was visited and
|
|
* the market skipped; a hike, a bakery of the trip and a place of another trip.
|
|
*/
|
|
async function fixture() {
|
|
const { user } = await makeUser(orm());
|
|
const { user: outsider } = await makeUser(orm());
|
|
const trip = await makeTrip(orm(), user.id);
|
|
const other = await makeTrip(orm(), user.id);
|
|
const day1 = await makeDay(orm(), trip.id, { day_number: 1 });
|
|
const walk = await makePlace(orm(), trip.id, { name: 'Old town' });
|
|
await makeTour(orm(), walk.id, { tourTypeRef: 'walk', revision: 3, distance: 1.6, duration: 22 });
|
|
await addTourWaypoints(orm(), walk.id, [
|
|
{ lat: 48.1, lng: 11.5, station_key: 'stop-gate', name: 'Gate', stay_minutes: 0 },
|
|
{ lat: 48.104, lng: 11.5, station_key: 'stop-cathedral', name: 'Cathedral', stay_minutes: 30, protected: 1 },
|
|
{ lat: 48.108, lng: 11.5, station_key: 'stop-market', name: 'Market', stay_minutes: 20 },
|
|
{ lat: 48.112, lng: 11.5, station_key: 'stop-park', name: 'Park', importance: 'must' },
|
|
]);
|
|
const onDay1 = await makeDayAssignment(orm(), day1.id, walk.id);
|
|
await markStationVisit(orm(), onDay1.id, 'stop-gate', 'visited');
|
|
await markStationVisit(orm(), onDay1.id, 'stop-market', 'skipped');
|
|
const ridge = await makePlace(orm(), trip.id, { name: 'Ridge' });
|
|
await makeTour(orm(), ridge.id);
|
|
await addTourWaypoints(orm(), ridge.id, [
|
|
{ lat: 47, lng: 11, station_key: 'pt-start' },
|
|
{ lat: 47.1, lng: 11, station_key: 'pt-end' },
|
|
]);
|
|
const bakery = await makePlace(orm(), trip.id, { name: 'Bakery', lat: 48.109, lng: 11.501 });
|
|
const foreign = await makePlace(orm(), other.id, { name: 'Foreign' });
|
|
return { user, outsider, trip, day1, walk, ridge, bakery, foreign };
|
|
}
|
|
|
|
/** Everything a preparation must leave as it is. */
|
|
const untouched = async (placeId: number) => [
|
|
await countRows(orm(), Places, {}),
|
|
await countRows(orm(), Tours, {}),
|
|
await countRows(orm(), TourWaypoints, {}),
|
|
await countRows(orm(), TourStationVisits, {}),
|
|
await countRows(orm(), DayAssignments, {}),
|
|
(await findRow(orm(), Tours, { place: placeId }))?.revision,
|
|
];
|
|
|
|
/** prepare_tour_change of one tour, as one traveller. */
|
|
const prepare = (tripId: number, placeId: number, changes: TourChange[], userId: number) =>
|
|
mcp.prepareTourChange({ tripId, placeId, changes }, ctx(userId));
|
|
|
|
/** The refusal a preparation raised, which the registry answers with its text. */
|
|
async function refusal(promise: Promise<unknown>): Promise<DomainError> {
|
|
const err = await catchDomainError(() => promise);
|
|
if (!(err instanceof DomainError)) throw new Error('no refusal');
|
|
return err;
|
|
}
|
|
|
|
describe('TourChangeMcp (MCP-T02)', () => {
|
|
it('TCHG-MCP-001: a spontaneous stop and a swap are prepared against the saved walk with their effects, and nothing is written', async () => {
|
|
const f = await fixture();
|
|
const before = await untouched(f.walk.id);
|
|
const changes: TourChange[] = [
|
|
{
|
|
op: 'add',
|
|
station: { lat: 48.109, lng: 11.501, name: 'Bakery', stay_minutes: 15, linked_place_id: f.bakery.id },
|
|
after_key: 'stop-market',
|
|
},
|
|
{ op: 'replace', key: 'stop-park', station: { lat: 48.113, lng: 11.502, name: 'Beer garden' } },
|
|
];
|
|
const proposal = read(await prepare(f.trip.id, f.walk.id, changes, f.user.id));
|
|
|
|
expect(proposal).toMatchObject({
|
|
prepared_only: true,
|
|
can_save: true,
|
|
base: { place_id: f.walk.id, name: 'Old town', tour_type: 'walk', revision: 3 },
|
|
changes,
|
|
});
|
|
expect(proposal.stations_after.map((s: { name: string; change: string }) => [s.name, s.change])).toEqual([
|
|
['Gate', 'kept'],
|
|
['Cathedral', 'kept'],
|
|
['Market', 'kept'],
|
|
['Bakery', 'added'],
|
|
['Beer garden', 'replaced'],
|
|
]);
|
|
const asked = (points: Array<[number, number]>) => points.map(([lat, lng]) => ({ lat, lng }));
|
|
const saved: Array<[number, number]> = [
|
|
[48.1, 11.5],
|
|
[48.104, 11.5],
|
|
[48.108, 11.5],
|
|
[48.112, 11.5],
|
|
];
|
|
expect(route).toHaveBeenCalledWith(f.user.id, f.trip.id, 0, asked(saved), 'walking', [], { footOnly: true });
|
|
expect(route).toHaveBeenCalledWith(
|
|
f.user.id,
|
|
f.trip.id,
|
|
0,
|
|
asked([...saved.slice(0, 3), [48.109, 11.501], [48.113, 11.502]]),
|
|
'walking',
|
|
[],
|
|
{ footOnly: true },
|
|
);
|
|
expect(proposal.effects.walking).toEqual({
|
|
planned_by: 'router_on_foot',
|
|
before: { planned: true, metres: 1500, minutes: 20 },
|
|
after: { planned: true, metres: 2000, minutes: 27 },
|
|
change: { metres: 500, minutes: 7 },
|
|
stored: { distance_km: 1.6, walking_minutes: 22 },
|
|
});
|
|
expect(proposal.effects.stays).toMatchObject({ change_minutes_known: 15, complete: false });
|
|
expect(proposal.warnings).toEqual([{ change: 2, key: 'stop-park', name: 'Park', why: 'must_station' }]);
|
|
// The Bakery is linked to a place of the trip already, so only the beer garden is offered to keep for later.
|
|
expect(proposal.for_later.map((stop: { name: string }) => stop.name)).toEqual(['Beer garden']);
|
|
expect(proposal.stations_after[3]).toMatchObject({ name: 'Bakery', linked_place_id: f.bakery.id });
|
|
expect(proposal.waypoints_after.map((p: { key?: string }) => p.key ?? null)).toEqual([
|
|
'stop-gate',
|
|
'stop-cathedral',
|
|
'stop-market',
|
|
null,
|
|
null,
|
|
]);
|
|
expect(await untouched(f.walk.id)).toEqual(before);
|
|
});
|
|
|
|
it('TCHG-MCP-002: what was done, a protected station and a place of another trip are refused with every reason, and nothing is written', async () => {
|
|
const f = await fixture();
|
|
const before = await untouched(f.walk.id);
|
|
const changes: TourChange[] = [
|
|
{ op: 'remove', key: 'stop-cathedral' },
|
|
{ op: 'move', key: 'stop-gate', after_key: 'stop-park' },
|
|
{ op: 'add', station: { lat: 48.1, lng: 11.5, linked_place_id: f.foreign.id }, after_key: 'stop-gate' },
|
|
];
|
|
const err = await refusal(prepare(f.trip.id, f.walk.id, changes, f.user.id));
|
|
expect([err.getStatus(), err.code]).toEqual([422, TOUR_CHANGE_REFUSED]);
|
|
expect(err.publicMessage.split('\n')).toEqual([
|
|
'Nothing was prepared and nothing changed.',
|
|
expect.stringMatching(/^Change 1 \(remove stop-cathedral\): this station is protected/),
|
|
expect.stringMatching(new RegExp(`^Change 2 \\(move stop-gate\\): .*day ids ${f.day1.id} visited\\.$`)),
|
|
expect.stringMatching(/^Change 3 \(add station\): the new station names a place that is not a place of/),
|
|
expect.stringMatching(/^Change 3 \(add stop-gate\): this would put a station before one/),
|
|
]);
|
|
expect((err.details?.refused as Array<{ reason: string }>).map((r) => r.reason)).toEqual([
|
|
'protected',
|
|
'done',
|
|
'place_not_linkable',
|
|
'before_done',
|
|
]);
|
|
expect(route).not.toHaveBeenCalled();
|
|
expect(await untouched(f.walk.id)).toEqual(before);
|
|
});
|
|
|
|
it('TCHG-MCP-003: a hike is refused as a type without changes, an outsider learns nothing and a place that is no tour is not found', async () => {
|
|
const f = await fixture();
|
|
const changes: TourChange[] = [{ op: 'remove', key: 'pt-start' }];
|
|
const hike = await refusal(prepare(f.trip.id, f.ridge.id, changes, f.user.id));
|
|
expect(hike.publicMessage).toContain('The proposal: only a city walk takes prepared changes so far');
|
|
expect(read(await prepare(f.trip.id, f.walk.id, changes, f.outsider.id))).toEqual({
|
|
error: 'Trip not found or access denied.',
|
|
});
|
|
expect(read(await prepare(f.trip.id, f.bakery.id, changes, f.user.id))).toEqual({ error: 'Tour not found.' });
|
|
const failure = new Error('database gone');
|
|
const servicePrepare = vi.spyOn(t.get(TourChangePreviewService), 'prepare').mockRejectedValueOnce(failure);
|
|
try {
|
|
await expect(prepare(f.trip.id, f.walk.id, changes, f.user.id)).rejects.toBe(failure);
|
|
} finally {
|
|
servicePrepare.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('TCHG-MCP-004: without a way from the router the walking effects stay unknown with why, and can_save follows place_edit', async () => {
|
|
const f = await fixture();
|
|
route.mockReset().mockRejectedValue(new Error('Routing provider returned 503'));
|
|
const guard = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValueOnce(false);
|
|
try {
|
|
const changes: TourChange[] = [{ op: 'remove', key: 'stop-park' }];
|
|
const proposal = read(await prepare(f.trip.id, f.walk.id, changes, f.user.id));
|
|
expect(guard).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
|
|
expect(proposal.can_save).toBe(false);
|
|
expect(proposal.effects.walking).toMatchObject({
|
|
before: { planned: false, why: 'router_unavailable' },
|
|
after: { planned: false, why: 'router_unavailable' },
|
|
change: null,
|
|
});
|
|
expect(proposal.removed).toEqual([{ key: 'stop-park', name: 'Park', was_position: 4, by_change: 1 }]);
|
|
expect(proposal.data_gaps.map((gap: { about: string; why: string }) => [gap.about, gap.why])).toEqual([
|
|
['walking', 'router_unavailable'],
|
|
['walking', 'router_unavailable'],
|
|
['arrival_times', 'no_start_time'],
|
|
]);
|
|
} finally {
|
|
guard.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('TCHG-MCP-005: the way on foot needs two points, and a router failure of any kind reads as router_unavailable', async () => {
|
|
const footWays = t.get(CityWalkFootWayService);
|
|
expect(await footWays.plan(1, 2, [{ lat: 48, lng: 11 }])).toEqual({
|
|
kind: 'unrouted',
|
|
why: 'nothing_to_route',
|
|
position: null,
|
|
});
|
|
expect(route).not.toHaveBeenCalled();
|
|
route.mockRejectedValueOnce('socket hang up');
|
|
const points = [
|
|
{ lat: 48, lng: 11 },
|
|
{ lat: 48.01, lng: 11 },
|
|
];
|
|
expect(await footWays.plan(1, 2, points)).toEqual({ kind: 'unrouted', why: 'router_unavailable', position: null });
|
|
});
|
|
|
|
it('TCHG-MCP-006: prepare_tour_change reads only, on the Tours addon, and says how a proposal is bound and saved', async () => {
|
|
const entry = getEntry(TourChangeMcp as unknown as ClassRef, 'prepareTourChange');
|
|
if (!entry || entry.kind !== 'tool') throw new Error('prepareTourChange is no MCP tool');
|
|
const { options } = entry;
|
|
expect([options.name, options.annotations?.readOnlyHint, options.access]).toEqual([
|
|
'prepare_tour_change',
|
|
true,
|
|
{ group: 'trips', mode: 'read' },
|
|
]);
|
|
const terms = ['revision', 'spontaneous stop', 'protected', 'visited or skipped', 'data_gaps', 'create_place'];
|
|
for (const term of terms) expect(options.description).toContain(term);
|
|
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
|
|
expect(await options.when?.(ctx(1), { addons: { isAddonEnabled } })).toBe(true);
|
|
expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
|
});
|
|
});
|