Files
TREK/server/tests/unit/nest/roadtrip-planning.test.ts
T
Maurice 182ed56fa4 places: let an admin share the details users typed with the TREK Places API
Every shareable field of a place now records where its value came from: typed
by a user, filled by a provider, written by the assistant, a plugin or an
import. Only values a user typed are ever sent, and only after an admin agreed
to the current terms for the whole installation, field by field.

The new place-contrib domain keeps the decision in app settings, queues values
in an outbox a cron job sends from, logs what the service made of each one and
can withdraw everything this installation sent. Development, test and demo
instances, an own TREK_PLACES_URL, TREK_SERVICES=off and
TREK_PLACES_CONTRIBUTE=off never send anything. Index results carry the
community version, so the details caches refresh when it changes.
2026-10-31 03:25:55 +00:00

835 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import type { McpContext } from '../../../src/nest-mcp';
import { createTestRegistry } from '../../../src/nest-mcp';
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
import { RoadtripPlanService } from '../../../src/nest/roadtrip/roadtrip-plan.service';
import { RoadtripPlanningMcp } from '../../../src/nest/roadtrip/roadtrip-planning.mcp';
import { RoadtripPreferencesMcp } from '../../../src/nest/roadtrip/roadtrip-preferences.mcp';
import { RoadtripPreferencesService } from '../../../src/nest/roadtrip/roadtrip-preferences.service';
import { RoadtripMcp } from '../../../src/nest/roadtrip/roadtrip.mcp';
import { callGatedTool } from '../../helpers/mcp-gate';
import { roadtripPreferencesUpdateSchema } from '@trek/shared';
import { describe, it, expect, vi } from 'vitest';
const ctx = { userId: 5 } as McpContext;
/**
* There is no database behind this suite — every statement is served by the
* fakes below — so the UnitOfWork is a pass-through: it runs the callback as
* MikroORM would, with no transaction to open on.
*/
const uowStub = { transactional: <T>(fn: () => Promise<T>) => fn() } as unknown as UnitOfWork;
function setup() {
const settings: Record<string, unknown> = {
roadtrip_day_start: '08:00',
roadtrip_day_end: '10:00',
roadtrip_vehicle: 'electric',
roadtrip_range_km: 100,
roadtrip_fill_percent: 80,
routing_base_url: 'https://private.example',
llm_api_key: 'secret',
};
const store = {
getUserSettings: vi.fn(() => settings),
};
const realtime = { broadcast: vi.fn() };
const tripSettings = new Map<number, Record<string, unknown>>([[10, { ...settings }]]);
const preferenceDb = {
listForTrip: (tripId: number) =>
Object.entries(tripSettings.get(tripId) ?? {}).map(([key, value]) => ({ key, value: JSON.stringify(value) })),
upsertValue: vi.fn((tripId: number, key: string, value: string) =>
tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }),
),
};
const preferences = new RoadtripPreferencesService(realtime as never, uowStub, preferenceDb as never);
const days = [{ id: 1, day_number: 1, title: null, date: '2026-09-11', default_transport_mode: 'driving' }];
const visits = [1, 2, 3].map((id) => ({
id,
day_id: 1,
place_id: id + 10,
name: `Stop ${id}`,
lat: 48,
lng: id,
time: id === 1 ? '07:00' : null,
duration_minutes: 30,
end_day: 0,
leg_transport_mode: null,
incoming_leg_transport_mode: null,
stop_type: null,
fill_percent: null,
}));
const tripsRepo = { findAccessible: vi.fn(async () => true) };
const daysRepo = { listPlanDays: vi.fn(async () => days) };
const dayAssignmentsRepo = { listRoadtripVisits: vi.fn(async () => visits) };
// The trip's stays, none unless a case books one.
const stays: Record<string, unknown>[] = [];
const dayAccommodationsRepo = { listRoadtripStays: vi.fn(async () => stays) };
// No carrier bookings in this suite: the rides are read off the real tables in
// roadtrip-plan.service.test.ts.
const reservationsRepo = { listRoadtripCarriers: vi.fn(async () => []), listRoadtripUndated: vi.fn(async () => []) };
const endpointsRepo = { listRoadtripTerminals: vi.fn(async () => []) };
const dayPositionsRepo = { listForTrip: vi.fn(async () => []) };
const router = {
profiles: () => ['driving'],
route: vi.fn(async (_user: number, _trip: number, _day: number, points: { lat: number; lng: number }[]) => ({
parts: points.slice(1).map(() => ({ distance: 120000, duration: 7200 })),
avoidMissed: [],
leg: {
line: points.map((p) => [p.lat, p.lng]),
vias: [],
seg: {
from: [48, 1],
to: [48, 3],
mid: [48, 2],
distance: 240000,
duration: 14400,
mode: 'driving',
distanceText: '',
drivingText: '',
walkingText: '',
},
},
})),
};
const roadtrip = { listForTrip: vi.fn(() => []), tracksForTrip: vi.fn(() => []) };
const boundaries = { list: vi.fn(() => []) };
const plans = new RoadtripPlanService(
store as never,
preferences,
router as never,
roadtrip as never,
boundaries as never,
tripsRepo as never,
daysRepo as never,
dayAssignmentsRepo as never,
dayAccommodationsRepo as never,
reservationsRepo as never,
endpointsRepo as never,
dayPositionsRepo as never,
);
return {
preferenceDb,
preferences,
store,
realtime,
tripsRepo,
daysRepo,
dayAssignmentsRepo,
dayAccommodationsRepo,
reservationsRepo,
plans,
router,
visits,
days,
boundaries,
stays,
};
}
describe('roadtrip preferences', () => {
it('reads only public driving preferences and never endpoint URLs or credentials', async () => {
const s = setup();
expect(await s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 });
expect(JSON.stringify(await s.preferences.read(10))).not.toMatch(/secret|private.example|routing_base_url/);
});
it('validates the complete window before an atomic write and broadcasts to every member of its trip', async () => {
const s = setup();
await expect(s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).rejects.toThrow();
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
await s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' });
// The fourth argument is the socket that saved, so the tab that made the
// change is left out of its own echo. Undefined here: no header was sent.
expect(s.realtime.broadcast).toHaveBeenCalledWith(
'10',
'roadtripPreferences:changed',
{
preferences: await s.preferences.read(10),
},
undefined,
);
expect((await s.preferences.read(10)).roadtrip_range_km).toBe(100);
});
it.each([
{ llm_api_key: 'x' },
{ routing_base_url: 'http://localhost' },
{ roadtrip_day_start: '25:00' },
{ roadtrip_fill_percent: 101 },
{ roadtrip_avoid: 'anything' },
{},
])('refuses invalid or unrelated preference %j', (patch) => {
expect(roadtripPreferencesUpdateSchema.safeParse(patch).success).toBe(false);
});
it('allows clearing daily times and limits and refuses demo writes', async () => {
const s = setup();
await s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 });
expect((await s.preferences.read(10)).roadtrip_day_start).toBe('');
const mcp = new RoadtripPreferencesMcp(s.preferences, {} as never, s.tripsRepo as never, {} as never);
s.preferenceDb.upsertValue.mockClear();
await callGatedTool(mcp, 'update', { tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx, async () => true);
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
});
it('saves the switch that starts and ends each day at the stay, through the tool too, and reads it back', async () => {
const s = setup();
expect((await s.preferences.read(10)).roadtrip_hotel_bookends).toBeUndefined();
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
);
const res = await mcp.update({ tripId: 10, settings: { roadtrip_hotel_bookends: true } }, ctx);
expect(JSON.parse(res.content[0].text as string).settings.roadtrip_hotel_bookends).toBe(true);
expect((await s.preferences.read(10)).roadtrip_hotel_bookends).toBe(true);
await s.preferences.update(10, { roadtrip_hotel_bookends: false });
expect((await s.preferences.read(10)).roadtrip_hotel_bookends).toBe(false);
expect(roadtripPreferencesUpdateSchema.safeParse({ roadtrip_hotel_bookends: 'yes' }).success).toBe(false);
});
it('tells the assistant why a window was refused instead of the exception class name', async () => {
// The service refuses an inverted window with the `{ error }` body the route
// sends verbatim; left to the SDK the tool would answer "Http Exception".
const s = setup();
const mcp = new RoadtripPreferencesMcp(
s.preferences,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
);
const res = await mcp.update({ tripId: 10, settings: { roadtrip_day_end: '06:00' } }, ctx);
expect([res.isError, res.content[0].text]).toEqual([true, 'Day end must be later than day start.']);
expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00');
});
});
describe('browser-independent roadtrip calculation', () => {
it('uses the shared scheduler, preserves the manually pinned start and routes a whole day in one request', async () => {
const s = setup();
const before = structuredClone(s.visits);
const plan = await s.plans.calculate(10, 5);
expect(s.router.route).toHaveBeenCalledTimes(1);
expect(plan.calculated.days[0].schedule.entries[0].arrival).toBe('07:00');
expect(plan.calculated.days.length).toBeGreaterThan(1);
expect(plan.calculated.days[0].stops.some((stop) => stop.automaticNight?.phase === 'end')).toBe(true);
expect(plan.calculated.days[0].driveWarnings.some((w) => w.code === 'range')).toBe(true);
expect(s.visits).toEqual(before);
});
it('previews settings without saving them', async () => {
const s = setup();
await s.plans.calculate(10, 5, { roadtrip_day_end: '20:00' });
expect((await s.preferences.read(10)).roadtrip_day_end).toBe('10:00');
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
});
it('previews a day that ends at tonight’s stay without saving the switch, and leaves it off otherwise', async () => {
const s = setup();
s.stays.push({
id: 7,
place_id: 70,
start_day_id: 1,
end_day_id: 2,
check_in: '15:00',
check_out: '10:00',
place_name: 'Tonight',
place_lat: 48.5,
place_lng: 4,
reservation_id: null,
});
const stored = await s.plans.calculate(10, 5);
expect(stored.calculated.days.flatMap((d) => d.stops).some((stop) => stop.bookend)).toBe(false);
const preview = await s.plans.calculate(10, 5, { roadtrip_hotel_bookends: true });
const hotel = preview.calculated.days.flatMap((d) => d.stops).find((stop) => stop.bookend);
expect(hotel).toMatchObject({
name: 'Tonight',
bookend: { phase: 'evening', accommodationId: 7, checkingIn: true },
});
expect((await s.preferences.read(10)).roadtrip_hotel_bookends).toBeUndefined();
expect(s.preferenceDb.upsertValue).not.toHaveBeenCalled();
});
it('checks trip access before reading or routing', async () => {
const s = setup();
s.tripsRepo.findAccessible.mockResolvedValue(false);
await expect(s.plans.calculate(20, 5)).rejects.toThrow();
expect(s.daysRepo.listPlanDays).not.toHaveBeenCalled();
expect(s.dayAssignmentsRepo.listRoadtripVisits).not.toHaveBeenCalled();
expect(s.dayAccommodationsRepo.listRoadtripStays).not.toHaveBeenCalled();
expect(s.reservationsRepo.listRoadtripCarriers).not.toHaveBeenCalled();
expect(s.reservationsRepo.listRoadtripUndated).not.toHaveBeenCalled();
expect(s.router.route).not.toHaveBeenCalled();
});
it('tells the assistant why a read, a calculation or a corridor search was refused', async () => {
// The service refuses with the `{ error }` body the route sends verbatim. Left
// to the SDK, the tool would answer "Http Exception" and the assistant could
// name no reason.
const s = setup();
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const reason = (res: { content: { text: string }[]; isError?: boolean }) => [res.isError, res.content[0].text];
s.tripsRepo.findAccessible.mockResolvedValue(false);
expect(reason(await mcp.context({ tripId: 20 }, ctx))).toEqual([true, 'Trip not found']);
expect(reason(await mcp.calculate({ tripId: 20, includeGeometry: false }, ctx))).toEqual([true, 'Trip not found']);
expect(
reason(await mcp.corridor({ tripId: 20, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 } as never, ctx)),
).toEqual([true, 'Trip not found']);
s.tripsRepo.findAccessible.mockResolvedValue(true);
const window = await mcp.calculate(
{ tripId: 10, includeGeometry: false, settings: { roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' } },
ctx,
);
expect(reason(window)).toEqual([true, 'Day end must be later than day start.']);
expect(s.router.route).not.toHaveBeenCalled();
});
it('marks provider failures incomplete and never leaks an endpoint from its exception', async () => {
const s = setup();
s.router.route.mockRejectedValue(new Error('secret https://private.example'));
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const answer = await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx);
expect(JSON.stringify(answer)).toContain('incomplete');
expect(JSON.stringify(answer)).not.toContain('private.example');
expect(JSON.stringify(answer)).toContain('false');
});
it('reports coordinate-less visits instead of inventing a location', async () => {
const s = setup();
Object.assign(s.visits[1], { lat: null });
const plan = await s.plans.calculate(10, 5);
expect(plan.omittedVisits).toEqual([2]);
});
it('hands calculate_roadtrip the end time of a visit as the moment the drive leaves it', async () => {
const s = setup();
// Stop 1 is left at 07:30 and the road takes two hours, so stop 2 is reached half an
// hour after the 09:00 it was meant to be left at. Stop 3 is left at noon, whatever
// its stay says.
Object.assign(s.visits[1], { end_time: '09:00' });
Object.assign(s.visits[2], { end_time: '12:00' });
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const answer = await mcp.calculate(
{ tripId: 10, includeGeometry: false, settings: { roadtrip_day_start: '', roadtrip_day_end: '' } },
ctx,
);
const body = JSON.parse(answer.content[0].text as string);
const [day] = body.days;
expect(day.stops.map((stop: { leaveAt: string | null }) => stop.leaveAt)).toEqual([undefined, '09:00', '12:00']);
expect(day.schedule.entries.map((e: { departure: string }) => e.departure)).toEqual(['07:30', '09:30', '12:00']);
expect(day.schedule.warnings).toEqual([{ index: 1, code: 'missedLeave', minutes: 30 }]);
});
it('keeps the per-leg lines the browser planner reads out of the calculate_roadtrip answer', async () => {
// The shared assembler now carries each leg's line beside its figures, for the picker of
// other ways in the browser. A tool answer carries geometry only when asked, and then
// once per day: the same road a second time, split by leg, is weight and nothing else.
const s = setup();
const settings = { roadtrip_day_start: '', roadtrip_day_end: '' };
const plan = await s.plans.calculate(10, 5, settings);
expect(plan.calculated.days[0].legLines).toHaveLength(plan.calculated.days[0].legs.length);
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const answer = await mcp.calculate({ tripId: 10, includeGeometry: true, settings }, ctx);
const [day] = JSON.parse(answer.content[0].text as string).days;
expect(day).toHaveProperty('geometry');
expect(day).not.toHaveProperty('legLines');
expect(day).not.toHaveProperty('arrivingLine');
});
it('answers calculate_roadtrip with the rides on no day, an empty list when there are none (#2461)', async () => {
// Additive: the rest of the answer is what it was. The rides themselves are read off the
// real tables in roadtrip-plan.service.test.ts; this pins the field and the words for it.
const s = setup();
const mcp = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
const body = JSON.parse(
(await mcp.calculate({ tripId: 10, includeGeometry: false }, ctx)).content[0].text as string,
);
expect(body.undatedRides).toEqual([]);
expect(body.days.length).toBeGreaterThan(0);
// And the tool says what the field is, or an assistant has no reason to read it.
const addons = { isAddonEnabled: vi.fn(() => true) };
const registry = createTestRegistry([new RoadtripPlanningMcp(s.plans, {} as never, addons as never)], {
accessPolicy: trekMcpAccessPolicy,
validateAccess: trekMcpValidateAccess,
});
const described = new Map<string, string>();
const registrar = {
registerTool: (name: string, config: { description?: string }) => {
described.set(name, config.description ?? '');
},
};
await registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
expect(described.get('calculate_roadtrip')).toContain('undatedRides');
});
it('keeps explicit end-day visits and manual boundaries in the shared planning path', async () => {
const s = setup();
s.visits[0].end_day = 1;
const plan = await s.plans.calculate(10, 5);
expect(plan.calculated.days[0].stops.find((stop) => stop.assignmentId === 1)?.endDay).toBe(true);
expect(plan.calculated.days[0].schedule.entries.some((entry) => entry.departure === '07:30')).toBe(true);
});
});
describe('Roadtrip MCP registration and search', () => {
it('imports GPX through the existing service and broadcasts the imported places', async () => {
const places = { importGpx: vi.fn(() => ({ places: [{ id: 12 }], count: 1, skipped: 0 })) };
const guards = { hasTripPermission: () => true, safeBroadcast: vi.fn() };
const mcp = new PlacesMcp(
places as never,
{} as never,
{ findAccessible: () => ({ id: 1, user_id: 1, currency: null }) } as never,
{} as never,
{} as never,
guards as never,
{} as never,
{ recordImported: vi.fn(async () => undefined) } as never,
);
const input = {
tripId: 1,
gpx: '<gpx/>',
name: 'Track',
importTracks: true,
importRoutes: false,
importWaypoints: false,
};
await mcp.importGpx(input, ctx);
expect(places.importGpx).toHaveBeenCalledWith('1', Buffer.from('<gpx/>'), {
defaultName: 'Track',
importTracks: true,
importRoutes: false,
importWaypoints: false,
});
expect(guards.safeBroadcast).toHaveBeenCalledWith(1, 'place:created', { place: { id: 12 } });
places.importGpx.mockClear();
await callGatedTool(mcp, 'importGpx', input, ctx, async () => true);
expect(places.importGpx).not.toHaveBeenCalled();
});
it('hides addon tools when disabled and separates settings reads from writes', async () => {
const s = setup();
const addons = { isAddonEnabled: vi.fn(() => true) };
const registry = createTestRegistry(
[
new RoadtripPreferencesMcp(s.preferences, addons as never, s.tripsRepo as never, {} as never),
new RoadtripPlanningMcp(s.plans, {} as never, addons as never),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
const names: string[] = [];
const registrar = {
registerTool: (name: string) => {
names.push(name);
},
};
await registry.attach(registrar as never, { ...ctx, scopes: ['trips:read'] });
expect(names).toContain('get_roadtrip_settings');
expect(names).not.toContain('update_roadtrip_settings');
names.length = 0;
addons.isAddonEnabled.mockReturnValue(false);
await registry.attach(registrar as never, { ...ctx, scopes: null });
expect(names).toEqual([]);
});
it('tells the assistant the stay switch is off until it is set, on every tool the switch changes', async () => {
const s = setup();
const registry = createTestRegistry(
[
new RoadtripPreferencesMcp(
s.preferences,
{ isAddonEnabled: () => true } as never,
s.tripsRepo as never,
{} as never,
),
new RoadtripPlanningMcp(s.plans, {} as never, { isAddonEnabled: () => true } as never),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
const descriptions: Record<string, string> = {};
await registry.attach(
{
registerTool: (name: string, config: Record<string, unknown>) => {
descriptions[name] = String(config.description);
},
} as never,
{ ...ctx, scopes: null },
);
expect(descriptions.get_roadtrip_settings).toContain('Missing roadtrip_hotel_bookends means off.');
expect(descriptions.update_roadtrip_settings).toContain(
'Set roadtrip_hotel_bookends true to start and end each day at the stay',
);
expect(descriptions.calculate_roadtrip).toContain('With roadtrip_hotel_bookends switched on (missing means off)');
expect(descriptions.calculate_roadtrip).toContain('{roadtrip_hotel_bookends:true}');
expect(descriptions.get_roadtrip_context).toContain('stays lists every booked stay');
expect(descriptions.search_roadtrip_corridor).toContain('With roadtrip_hotel_bookends on');
});
it('returns corridor sources, truncation and matching brands without adding stops', async () => {
const plans = {
calculate: vi.fn(async () => ({
failures: [],
omittedVisits: [],
calculated: {
days: [
{
dayNumber: 1,
stops: [],
geometry: [
[48, 10],
[48, 10.1],
],
},
],
dayWindowIssue: null,
},
})),
};
const maps = {
search: vi.fn(async () => ({
pois: [{ osm_id: 'n1', name: 'Fuel', brand: 'Example', lat: 48, lng: 10.05, category: 'fuel' }],
sources: ['trek-places'],
failedSources: [],
truncated: true,
clamped: false,
})),
};
const mcp = new RoadtripPlanningMcp(plans as never, maps as never, {} as never);
const answer = await mcp.corridor(
{ tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0, name: 'example' },
ctx,
);
const body = JSON.parse(answer.content[0].text as string);
expect(body.hits).toHaveLength(1);
expect(body.sources).toEqual(['trek-places']);
expect(body.truncatedAreas).toBeGreaterThan(0);
expect(body.complete).toBe(false);
maps.search.mockRejectedValue(new Error('private endpoint'));
const failed = await mcp.corridor({ tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 }, ctx);
expect(JSON.stringify(failed)).toContain('failedAreas');
expect(JSON.stringify(failed)).not.toContain('private endpoint');
});
it('checks visit editing permission and ownership before moving a via', async () => {
const service = {
dayExists: vi.fn(() => true),
moveVia: vi.fn(() => ({ id: 4 })),
};
// The move and what it tells the trip are the service's; the tool hands it the MCP writer.
const writer = { userId: 5, surface: 'mcp' };
const guards = { hasTripPermission: vi.fn(() => false), tripWriter: vi.fn(async () => writer) };
const mcp = new RoadtripMcp(
service as never,
{ findAccessible: () => true } as never,
guards as never,
{} as never,
);
const input = { tripId: 1, dayId: 2, viaId: 4, lat: 48, lng: 10 };
await mcp.updateVia(input, ctx);
expect(service.moveVia).not.toHaveBeenCalled();
guards.hasTripPermission.mockReturnValue(true);
service.dayExists.mockReturnValue(false);
await mcp.updateVia(input, ctx);
expect(service.moveVia).not.toHaveBeenCalled();
service.dayExists.mockReturnValue(true);
await mcp.updateVia(input, ctx);
expect(guards.tripWriter).toHaveBeenCalledWith(1, 5);
expect(service.moveVia).toHaveBeenCalledWith(2, 4, { lat: 48, lng: 10, after_order_index: undefined }, writer);
});
});
// Item 11 (Plan 3d Task 7 whole-plan review, L5): `updateVia` above is the
// only tool in this class with its OWN non-member case — the mutation
// coverage reaches every tool (they all gate on the same `findAccessible`
// call), but only one had a dedicated test. One per tool, as
// `tools-days-accommodations` already does.
describe('RoadtripMcp — non-member refusal, one case per tool (Plan 3d Task 7 review, item 11)', () => {
function build(findAccessible: boolean) {
const service = {
dayExists: vi.fn(async () => true),
listForDay: vi.fn(async () => []),
listForTrip: vi.fn(async () => []),
tracksForTrip: vi.fn(async () => []),
trackExists: vi.fn(async () => true),
addVia: vi.fn(async () => ({ id: 1 })),
addVias: vi.fn(async () => []),
reanchorVias: vi.fn(async () => []),
removeVia: vi.fn(async () => true),
moveVia: vi.fn(async () => ({ id: 1 })),
};
const tripsRepo = { findAccessible: vi.fn(async () => findAccessible) };
const guards = { hasTripPermission: vi.fn(async () => true), tripWriter: vi.fn(async () => ({ userId: 5 })) };
const mcp = new RoadtripMcp(service as never, tripsRepo as never, guards as never, {} as never);
return { mcp, service, tripsRepo };
}
it('list_route_vias refuses a non-member trip (the legacy "not found or access denied" shape)', async () => {
const { mcp, service } = build(false);
const res = await mcp.listVias({ tripId: 10 }, ctx);
expect(res.isError).toBe(true);
expect(JSON.stringify(res)).toContain('access denied');
expect(service.listForTrip).not.toHaveBeenCalled();
});
it('add_route_via refuses a non-member trip', async () => {
const { mcp, service } = build(false);
const res = await mcp.addVia({ tripId: 10, dayId: 1, after_order_index: 0, lat: 48, lng: 10 }, ctx);
expect(res.isError).toBe(true);
expect(service.addVia).not.toHaveBeenCalled();
});
it('add_route_vias refuses a non-member trip', async () => {
const { mcp, service } = build(false);
const res = await mcp.addVias({ tripId: 10, dayId: 1, vias: [] }, ctx);
expect(res.isError).toBe(true);
expect(service.addVias).not.toHaveBeenCalled();
});
it('reanchor_route_vias refuses a non-member trip', async () => {
const { mcp, service } = build(false);
const res = await mcp.reanchorVias({ tripId: 10, dayId: 1, vias: [] }, ctx);
expect(res.isError).toBe(true);
expect(service.reanchorVias).not.toHaveBeenCalled();
});
it('remove_route_via refuses a non-member trip', async () => {
const { mcp, service } = build(false);
const res = await mcp.removeVia({ tripId: 10, dayId: 1, viaId: 4 }, ctx);
expect(res.isError).toBe(true);
expect(service.removeVia).not.toHaveBeenCalled();
});
});
describe('MCP trip preferences authorization', () => {
it('reads shared preferences and refuses inaccessible trips', async () => {
const s = setup();
const tool = new RoadtripPreferencesMcp(
s.preferences,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
);
expect(JSON.stringify(await tool.read({ tripId: 10 }, ctx))).toContain('100');
s.tripsRepo.findAccessible.mockResolvedValue(false);
expect((await tool.read({ tripId: 10 }, ctx)).isError).toBe(true);
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
});
// Item 11 (Plan 3d Task 7 whole-plan review, L5): `update_roadtrip_settings`
// gets its OWN non-member case — the test above already asserts it as a
// second assertion piggybacked on the `read` test, but not in isolation.
it('update_roadtrip_settings refuses a non-member trip on its own, without a prior read call', async () => {
const s = setup();
s.tripsRepo.findAccessible.mockResolvedValue(false);
const tool = new RoadtripPreferencesMcp(
s.preferences,
{} as never,
s.tripsRepo as never,
{ hasTripPermission: () => true } as never,
);
const res = await tool.update({ tripId: 10, settings: { roadtrip_range_km: 50 } }, ctx);
expect(res.isError).toBe(true);
expect(JSON.stringify(res)).toContain('access denied');
});
it('rejects demos and readers, but permits a trip editor', async () => {
const s = setup();
const guards = { hasTripPermission: vi.fn(() => false) };
const tool = new RoadtripPreferencesMcp(s.preferences, {} as never, s.tripsRepo as never, guards as never);
expect(await callGatedTool(tool, 'update', { tripId: 10, settings: {} }, ctx, async () => true)).toMatchObject({
isError: true,
});
expect((await tool.update({ tripId: 10, settings: {} }, ctx)).isError).toBe(true);
guards.hasTripPermission.mockReturnValue(true);
expect(JSON.stringify(await tool.update({ tripId: 10, settings: { roadtrip_range_km: 120 } }, ctx))).toContain(
'120',
);
});
});
describe('corridor filtering', () => {
const input = { tripId: 10, dayNumber: 1, category: 'charging' as const, widthKm: 5, offset: 0 };
function tool() {
const plan = {
failures: [] as unknown[],
omittedVisits: [] as number[],
calculated: {
dayWindowIssue: null as string | null,
days: [
{
dayNumber: 1,
stops: [],
geometry: [
[48, 10],
[48, 10.1],
],
},
],
},
};
const maps = {
search: vi.fn(async () => ({
pois: [
{
osm_id: 'a',
name: 'Fast',
lat: 48,
lng: 10.03,
category: 'charging',
charging: { sockets: [{ type: 'type2', kw: 150 }] },
},
{
osm_id: 'b',
name: 'Slow',
lat: 48,
lng: 10.07,
category: 'charging',
charging: { sockets: [{ type: 'type2', kw: 11 }] },
},
{
osm_id: 'c',
name: 'Unknown',
lat: 48,
lng: 10.05,
category: 'charging',
charging: { sockets: [{ type: 'ccs', kw: null }] },
},
{ osm_id: 'd', name: 'Far', lat: 50, lng: 12, category: 'charging' },
],
sources: ['osm'],
failedSources: ['plugin'],
truncated: false,
clamped: false,
})),
};
return {
plan,
maps,
mcp: new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never),
};
}
it('refuses failed routes, absent days and reversed search ranges', async () => {
const s = tool();
s.plan.failures.push({});
expect((await s.mcp.corridor(input, ctx)).isError).toBe(true);
s.plan.failures = [];
s.plan.omittedVisits = [1];
expect((await s.mcp.corridor(input, ctx)).isError).toBe(true);
s.plan.omittedVisits = [];
s.plan.calculated.dayWindowIssue = 'conflict';
expect((await s.mcp.corridor(input, ctx)).isError).toBe(true);
s.plan.calculated.dayWindowIssue = null;
expect((await s.mcp.corridor({ ...input, fromKm: 8, toKm: 2 }, ctx)).isError).toBe(true);
expect((await s.mcp.corridor({ ...input, dayNumber: 2 }, ctx)).isError).toBe(true);
expect(s.maps.search).not.toHaveBeenCalled();
});
it('filters sockets, power, name and along-route bounds without hiding source failures', async () => {
const s = tool();
const read = async (over: Partial<Parameters<RoadtripPlanningMcp['corridor']>[0]>) =>
JSON.parse((await s.mcp.corridor({ ...input, ...over }, ctx)).content[0].text as string);
expect(
(await read({ socket: 'type2', minKw: 100 })).hits.map((h: { poi: { name: string } }) => h.poi.name),
).toEqual(['Fast']);
expect((await read({ name: 'unknown' })).hits).toHaveLength(1);
expect((await read({ fromKm: 100 })).hits).toHaveLength(0);
expect((await read({ toKm: 0 })).hits).toHaveLength(0);
expect((await read({})).failedSources).toEqual(['plugin']);
});
it('RTMCP-001: names the two stops of the day each hit lies between, and none on a day of one stop', async () => {
const s = tool();
s.plan.calculated.days[0].stops = [
{ name: 'Augsburg', lat: 48, lng: 10 },
{ name: 'Ulm', lat: 48, lng: 10.06 },
{ name: 'Stuttgart', lat: 48, lng: 10.1 },
] as never;
const read = async () => JSON.parse((await s.mcp.corridor(input, ctx)).content[0].text as string);
const between = (body: { hits: { poi: { name: string }; between: unknown }[] }) =>
Object.fromEntries(body.hits.map((hit) => [hit.poi.name, hit.between]));
expect(between(await read())).toEqual({
Fast: ['Augsburg', 'Ulm'],
Unknown: ['Augsburg', 'Ulm'],
Slow: ['Ulm', 'Stuttgart'],
});
s.plan.calculated.days[0].stops = [{ name: 'Augsburg', lat: 48, lng: 10 }] as never;
expect(Object.values(between(await read()))).toEqual([null, null, null]);
});
it('RTMCP-002: places each stop after the previous one, so a day crossing itself names the right pair', async () => {
const s = tool();
// East, north, then south across the first stretch at (48, 10.05) and on below it.
s.plan.calculated.days[0].geometry = [
[48, 10],
[48, 10.1],
[48.05, 10.1],
[48.05, 10.05],
[47.95, 10.05],
];
s.plan.calculated.days[0].stops = [
{ name: 'Start', lat: 48, lng: 10 },
{ name: 'Corner', lat: 48.05, lng: 10.1 },
{ name: 'Crossing', lat: 48, lng: 10.05 },
{ name: 'End', lat: 47.95, lng: 10.05 },
] as never;
s.maps.search.mockResolvedValue({
pois: [{ osm_id: 'x', name: 'Before the crossing', lat: 48.03, lng: 10.05, category: 'charging' }],
sources: ['osm'],
failedSources: [],
truncated: false,
clamped: false,
} as never);
const body = JSON.parse((await s.mcp.corridor(input, ctx)).content[0].text as string);
expect(body.hits.map((hit: { between: unknown }) => hit.between)).toEqual([['Corner', 'Crossing']]);
});
it('leaves the ride between two terminals out of the search and drops a hit under the flight path (#2428)', async () => {
// The day drives to the airport, flies two degrees north and east, and drives on
// from the far one: the geometry jumps straight between the terminals, and the
// browser hook already cuts that stretch out. The tool tiles the same pieces.
const terminal = (role: 'departure' | 'arrival', lat: number, lng: number) => ({
carrier: { reservationId: 7, type: 'flight', role, title: 'LH 2020', code: null, at: null },
lat,
lng,
});
const plan = {
failures: [],
omittedVisits: [],
calculated: {
dayWindowIssue: null,
days: [
{
dayNumber: 1,
stops: [
{ lat: 48, lng: 10 },
terminal('departure', 48, 10.1),
terminal('arrival', 50, 12),
{ lat: 50, lng: 12.1 },
],
geometry: [
[48, 10],
[48, 10.1],
[50, 12],
[50, 12.1],
],
},
],
},
};
const underTheFlight = { lat: 49, lng: 11 };
type Box = { south: number; north: number; west: number; east: number };
const maps = {
search: vi.fn(async (_query: { bbox: Box }) => ({
pois: [
{ osm_id: 'road', name: 'On the road', lat: 48, lng: 10.05, category: 'fuel' },
{ osm_id: 'field', name: 'Under the flight', ...underTheFlight, category: 'fuel' },
],
sources: ['osm'],
failedSources: [],
truncated: false,
clamped: false,
})),
};
const mcp = new RoadtripPlanningMcp({ calculate: async () => plan } as never, maps as never, {} as never);
const body = JSON.parse((await mcp.corridor({ ...input, category: 'fuel' }, ctx)).content[0].text as string);
const asked = maps.search.mock.calls.map(([query]) => query.bbox);
expect(asked.length).toBeGreaterThan(0);
expect(
asked.some(
(b) =>
b.south <= underTheFlight.lat &&
b.north >= underTheFlight.lat &&
b.west <= underTheFlight.lng &&
b.east >= underTheFlight.lng,
),
).toBe(false);
expect(body.hits.map((h: { poi: { osm_id: string } }) => h.poi.osm_id)).toEqual(['road']);
expect(body.totalAreas).toBe(asked.length);
expect(body.complete).toBe(true);
});
});
it('reads the saved roadtrip context without routing', async () => {
const s = setup();
const tool = new RoadtripPlanningMcp(s.plans, {} as never, {} as never);
expect(JSON.stringify(await tool.context({ tripId: 10 }, ctx))).toContain('Stop 1');
expect(s.router.route).not.toHaveBeenCalled();
});