import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; import type { McpContext } from '../../../src/nest-mcp'; import { createTestRegistry } from '../../../src/nest-mcp'; 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 { roadtripPreferencesUpdateSchema } from '@trek/shared'; import { describe, it, expect, vi } from 'vitest'; const ctx = { userId: 5 } as McpContext; function setup() { const settings: Record = { 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>([[10, { ...settings }]]); const preferenceDb = { all: (_sql: string, tripId: number) => Object.entries(tripSettings.get(tripId) ?? {}).map(([key, value]) => ({ key, value: JSON.stringify(value) })), run: vi.fn((_sql: string, tripId: number, key: string, value: string) => tripSettings.set(tripId, { ...tripSettings.get(tripId), [key]: JSON.parse(value) }), ), transaction: (fn: () => unknown) => fn(), }; const preferences = new RoadtripPreferencesService(preferenceDb as never, realtime 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 db = { canAccessTrip: vi.fn(() => true), all: vi.fn((sql: string) => (sql.includes('FROM day_assignments') ? visits : days)), }; 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( db as never, store as never, preferences, router as never, roadtrip as never, boundaries as never, ); return { preferenceDb, preferences, store, realtime, db, plans, router, visits, days, boundaries }; } describe('roadtrip preferences', () => { it('reads only public driving preferences and never endpoint URLs or credentials', () => { const s = setup(); expect(s.preferences.read(10)).toMatchObject({ roadtrip_range_km: 100 }); expect(JSON.stringify(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', () => { const s = setup(); expect(() => s.preferences.update(10, { roadtrip_day_end: '06:00', roadtrip_range_km: 200 })).toThrow(); expect(s.preferenceDb.run).not.toHaveBeenCalled(); s.preferences.update(10, { roadtrip_day_start: '06:00', roadtrip_day_end: '09:00' }); expect(s.realtime.broadcast).toHaveBeenCalledWith('10', 'roadtripPreferences:changed', { preferences: s.preferences.read(10), }); expect(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(); s.preferences.update(10, { roadtrip_day_start: '', roadtrip_range_km: 0 }); expect(s.preferences.read(10).roadtrip_day_start).toBe(''); const mcp = new RoadtripPreferencesMcp( s.preferences, { isDemoUser: () => true } as never, {} as never, s.db as never, {} as never, ); s.preferenceDb.run.mockClear(); await mcp.update({ tripId: 10, settings: { roadtrip_range_km: 300 } }, ctx); expect(s.preferenceDb.run).not.toHaveBeenCalled(); }); }); 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(s.preferences.read(10).roadtrip_day_end).toBe('10:00'); expect(s.preferenceDb.run).not.toHaveBeenCalled(); }); it('checks trip access before reading or routing', async () => { const s = setup(); s.db.canAccessTrip.mockReturnValue(false); await expect(s.plans.calculate(20, 5)).rejects.toThrow(); expect(s.db.all).not.toHaveBeenCalled(); 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('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 auth = { isDemoUser: vi.fn(() => false) }; const guards = { hasTripPermission: () => true, safeBroadcast: vi.fn() }; const mcp = new PlacesMcp( places as never, {} as never, { canAccessTrip: () => true } as never, auth as never, {} as never, {} as never, guards as never, ); const input = { tripId: 1, gpx: '', name: 'Track', importTracks: true, importRoutes: false, importWaypoints: false, }; await mcp.importGpx(input, ctx); expect(places.importGpx).toHaveBeenCalledWith('1', Buffer.from(''), { defaultName: 'Track', importTracks: true, importRoutes: false, importWaypoints: false, }); expect(guards.safeBroadcast).toHaveBeenCalledWith(1, 'place:created', { place: { id: 12 } }); places.importGpx.mockClear(); auth.isDemoUser.mockReturnValue(true); await mcp.importGpx(input, ctx); expect(places.importGpx).not.toHaveBeenCalled(); }); it('hides addon tools when disabled and separates settings reads from writes', () => { const s = setup(); const addons = { isAddonEnabled: vi.fn(() => true) }; const registry = createTestRegistry( [ new RoadtripPreferencesMcp(s.preferences, {} as never, addons as never, s.db 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); }, }; 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); registry.attach(registrar as never, { ...ctx, scopes: null }); expect(names).toEqual([]); }); it('returns corridor sources, truncation and matching brands without adding stops', async () => { const plans = { calculate: vi.fn(async () => ({ failures: [], omittedVisits: [], calculated: { days: [ { dayNumber: 1, 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), move: vi.fn(() => ({ id: 4 })), listForDay: () => [], broadcast: vi.fn(), }; const guards = { hasTripPermission: vi.fn(() => false) }; const mcp = new RoadtripMcp( service as never, { canAccessTrip: () => true } as never, guards as never, { isDemoUser: () => false } as never, {} as never, ); const input = { tripId: 1, dayId: 2, viaId: 4, lat: 48, lng: 10 }; await mcp.updateVia(input, ctx); expect(service.move).not.toHaveBeenCalled(); guards.hasTripPermission.mockReturnValue(true); service.dayExists.mockReturnValue(false); await mcp.updateVia(input, ctx); expect(service.move).not.toHaveBeenCalled(); service.dayExists.mockReturnValue(true); await mcp.updateVia(input, ctx); expect(service.move).toHaveBeenCalledWith(4, 2, 48, 10, undefined); expect(service.broadcast).toHaveBeenCalled(); }); });