mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(mcp): place pictures, POI search, GPX export and a biased place search
image_url was in no places tool although the service persists it and the picker in the UI sets a URL, which is not a file transport question. search_place passed two of the service's four arguments, so an unbiased query resolved to the wrong continent. get_place_details gained expand for reviews and the editorial summary. It defaults to off because that field mask bills as a Google Enterprise SKU, and it now honours the admin kill switch the REST route checks: an instance owner who turns Place Details off to stop the billing was still billable through this one surface.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { POI_CATEGORY_KEYS } from './maps.helpers';
|
||||
import { MapsService } from './maps.service';
|
||||
|
||||
/**
|
||||
@@ -16,16 +17,38 @@ export class MapsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'get_place_details',
|
||||
description: 'Fetch detailed information about a place by its Google Place ID.',
|
||||
description: 'Fetch detailed information about a place by its Google Place ID. The plain lookup returns name, address, coordinates, rating, opening hours, phone and website, and always answers with an empty review list. Set expand only when visitor reviews or the editorial summary are what was actually asked for: that field mask bills as a Google Enterprise SKU and costs the instance owner several times a plain lookup.',
|
||||
inputSchema: {
|
||||
placeId: z.string().describe('Google Place ID'),
|
||||
lang: z.string().optional().default('en'),
|
||||
expand: z.boolean().optional().default(false).describe('Also fetch up to five visitor reviews and the editorial summary. Billed as an Enterprise SKU, so leave it off unless the answer needs them'),
|
||||
refresh: z.boolean().optional().default(false).describe('Bypass the cached expanded payload and re-fetch from the provider. Only does anything together with expand, and pays the expanded price again'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
})
|
||||
async getPlaceDetails({ placeId, lang }: { placeId: string; lang?: string }, ctx: McpContext) {
|
||||
const details = await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en');
|
||||
async getPlaceDetails(
|
||||
{ placeId, lang, expand, refresh }: { placeId: string; lang?: string; expand?: boolean; refresh?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
// The admin kill switch, checked before either path. An instance owner who
|
||||
// turns Place Details off does it to stop Google billing them, and an
|
||||
// assistant reaching the provider anyway would bill them from the one
|
||||
// surface the switch does not cover. REST answers { place: null, disabled }
|
||||
// rather than an error, so this does too, with a line the model can act on.
|
||||
if (this.maps.detailsDisabled()) {
|
||||
return ok({
|
||||
details: null,
|
||||
disabled: true,
|
||||
note: 'Place Details is turned off on this instance by an administrator. Nothing was fetched.',
|
||||
});
|
||||
}
|
||||
|
||||
// Same split the REST route makes, and for the same reason: the expanded
|
||||
// field mask is the expensive one, so nothing reaches it unless it was asked for.
|
||||
const details = expand
|
||||
? await this.maps.getPlaceDetailsExpanded(ctx.userId, placeId, lang ?? 'en', refresh ?? false)
|
||||
: await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en');
|
||||
return ok({ details });
|
||||
}
|
||||
|
||||
@@ -58,4 +81,41 @@ export class MapsMcp {
|
||||
const result = await this.maps.resolveGoogleMapsUrl(url);
|
||||
return ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP side of GET /api/maps/pois, the "explore" pill on the trip map. Not
|
||||
* part of the 1:1 move above: the route had no tool at all, which left the
|
||||
* whole discover-what-is-here half of the map unreachable from a conversation.
|
||||
*/
|
||||
@Tool({
|
||||
name: 'search_pois',
|
||||
description: 'List OpenStreetMap points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever OSM carries them. This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
|
||||
inputSchema: {
|
||||
category: z.enum(POI_CATEGORY_KEYS).describe('Which kind of place to look for'),
|
||||
bbox: z.object({
|
||||
south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
|
||||
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
|
||||
north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
|
||||
east: z.number().min(-180).max(180).describe('Eastern edge, longitude'),
|
||||
}).describe('The rectangle to search. Anything wider than 0.5 degrees is narrowed to a centred window so the query stays fast; the answer reports that as `clamped`'),
|
||||
lang: z.string().max(35).optional().describe('Language for the POI names, e.g. "de" or "ja". Falls back to the OSM international name and then the local one'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
})
|
||||
async searchPois(
|
||||
{ category, bbox, lang }: {
|
||||
category: string;
|
||||
bbox: { south: number; west: number; north: number; east: number };
|
||||
lang?: string;
|
||||
},
|
||||
_ctx: McpContext,
|
||||
) {
|
||||
try {
|
||||
return ok(await this.maps.pois(category, bbox, lang));
|
||||
} catch {
|
||||
// Overpass is a set of public mirrors; an outage there is not a bad request.
|
||||
return errorResult('POI search failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import {
|
||||
McpController, Tool, ResourceTemplate, type McpContext,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
demoDenied, errorResult, ok,
|
||||
} from '../../nest-mcp';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { placeWebsiteSchema } from '@trek/shared';
|
||||
import {
|
||||
mapsSearchRequestSchema,
|
||||
placeImageUrlSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeWebsiteSchema,
|
||||
} from '@trek/shared';
|
||||
import { z } from 'zod';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
@@ -66,6 +71,7 @@ export class PlacesMcp {
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
@@ -73,17 +79,17 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createPlace(
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }: {
|
||||
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency }: {
|
||||
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
|
||||
notes?: string; website?: string; phone?: string; price?: number; currency?: string;
|
||||
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, image_url, price, currency });
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -106,6 +112,7 @@ export class PlacesMcp {
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
@@ -114,10 +121,10 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async createAndAssignPlace(
|
||||
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }: {
|
||||
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, image_url, assignment_notes, price, currency }: {
|
||||
tripId: number; dayId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
|
||||
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string;
|
||||
place_notes?: string; website?: string; phone?: string; assignment_notes?: string;
|
||||
place_notes?: string; website?: string; phone?: string; image_url?: string; assignment_notes?: string;
|
||||
price?: number; currency?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
@@ -128,7 +135,7 @@ export class PlacesMcp {
|
||||
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const result = this.db.transaction(() => {
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, image_url, price, currency });
|
||||
const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
@@ -161,6 +168,7 @@ export class PlacesMcp {
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to remove the current picture'),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||
@@ -170,10 +178,11 @@ export class PlacesMcp {
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async updatePlace(
|
||||
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }: {
|
||||
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid }: {
|
||||
tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number;
|
||||
address?: string; category_id?: number; price?: number; currency?: string; place_time?: string;
|
||||
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
|
||||
image_url?: string | null;
|
||||
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string;
|
||||
google_place_id?: string; google_ftid?: string;
|
||||
},
|
||||
@@ -182,7 +191,7 @@ export class PlacesMcp {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
@@ -274,19 +283,26 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'search_place',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
|
||||
lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async searchPlace({ query }: { query: string }, ctx: McpContext) {
|
||||
async searchPlace(
|
||||
{ query, locationBias, lang }: {
|
||||
query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
try {
|
||||
const result = await this.maps.searchPlaces(ctx.userId, query);
|
||||
const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias);
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||
return errorResult('Place search failed.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,21 +313,25 @@ export class PlacesMcp {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
enrich: placeImportListRequestSchema.shape.enrich.describe('Re-resolve every imported place through the Places API afterwards to fill in photo, address, website and phone (#886). Needs a Google Maps key on the instance, costs a lookup per place, and runs in the background: the tool returns the bare import and the places fill in over the websocket. Off by default'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async importPlacesFromUrl(
|
||||
{ tripId, url, source }: { tripId: number; url: string; source: 'google-list' | 'naver-list' },
|
||||
{ tripId, url, source, enrich }: { tripId: number; url: string; source: 'google-list' | 'naver-list'; enrich?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
// Same opts the REST route builds: the enrichment pass is keyed on the calling
|
||||
// user because it spends that user's Places credential.
|
||||
const opts = { enrich: enrich ?? false, userId: ctx.userId };
|
||||
const result = source === 'google-list'
|
||||
? await this.places.importGoogleList(String(tripId), url)
|
||||
: await this.places.importNaverList(String(tripId), url);
|
||||
? await this.places.importGoogleList(String(tripId), url, opts)
|
||||
: await this.places.importNaverList(String(tripId), url, opts);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
@@ -323,6 +343,32 @@ export class PlacesMcp {
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'export_trip_gpx',
|
||||
description: 'Export a trip as GPX text: its places as waypoints, any imported routes as tracks, and each planned day as a route in visiting order. This is the format handhelds and offline map apps (Organic Maps, OsmAnd, Garmin) read. Prefer export_trip_ics when the user wants the itinerary in a calendar instead.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
waypoints: z.boolean().optional().default(true).describe('Write every place with coordinates as a <wpt>'),
|
||||
tracks: z.boolean().optional().default(true).describe('Write places that carry an imported route geometry as a <trk>'),
|
||||
dayRoutes: z.boolean().optional().default(true).describe('Write each planned day as a <rte> through its stops in order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'places', mode: 'read' },
|
||||
})
|
||||
async exportTripGpx(
|
||||
{ tripId, waypoints, tracks, dayRoutes }: {
|
||||
tripId: number; waypoints?: boolean; tracks?: boolean; dayRoutes?: boolean;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
// A read, like the REST route: seeing the trip is enough, no place_edit.
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!waypoints && !tracks && !dayRoutes) return errorResult('No export types selected.');
|
||||
const result = this.places.exportGpx(String(tripId), { waypoints, tracks, dayRoutes });
|
||||
if (!result) return errorResult('Nothing to export.');
|
||||
return ok({ gpx: result.gpx, filename: result.filename });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_delete_places',
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
@@ -369,17 +415,18 @@ export class PlacesMcp {
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: placeWebsiteSchema.optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'),
|
||||
description: z.string().max(2000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'places', mode: 'write' },
|
||||
})
|
||||
async bulkUpdatePlaces(
|
||||
{ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description }: {
|
||||
{ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description }: {
|
||||
tripId: number; placeIds: number[]; category_id?: number; price?: number; currency?: string;
|
||||
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; place_time?: string;
|
||||
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
|
||||
description?: string;
|
||||
image_url?: string | null; description?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
@@ -387,7 +434,7 @@ export class PlacesMcp {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description };
|
||||
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description };
|
||||
if (Object.values(fields).every(v => v === undefined)) {
|
||||
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ function linkJourney(journeyId: number, tripId: number) {
|
||||
function skeletonFor(journeyId: number, placeId: number) {
|
||||
return testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journeyId, placeId) as any;
|
||||
}
|
||||
/** The stored thumbnail, read off the row rather than out of the tool's echo. */
|
||||
function imageOf(placeId: number): string | null {
|
||||
return (testDb.prepare('SELECT image_url FROM places WHERE id = ?').get(placeId) as { image_url: string | null }).image_url;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -133,6 +137,54 @@ describe('Tool: create_place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('persists image_url on the row (#37)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'create_place',
|
||||
arguments: { tripId: trip.id, name: 'Pictured', image_url: 'https://cdn.example.com/spot.jpg' },
|
||||
});
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT image_url FROM places WHERE trip_id = ? AND name = ?').get(trip.id, 'Pictured') as { image_url: string };
|
||||
expect(row.image_url).toBe('https://cdn.example.com/spot.jpg');
|
||||
});
|
||||
|
||||
it('accepts the photo-proxy path the place picker hands out', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'create_place',
|
||||
arguments: { tripId: trip.id, name: 'Proxied', image_url: '/api/maps/place-photo/ChIJabc~p0/bytes' },
|
||||
});
|
||||
});
|
||||
|
||||
const row = testDb.prepare('SELECT image_url FROM places WHERE trip_id = ? AND name = ?').get(trip.id, 'Proxied') as { image_url: string };
|
||||
expect(row.image_url).toBe('/api/maps/place-photo/ChIJabc~p0/bytes');
|
||||
});
|
||||
|
||||
// The marker builders assemble their HTML as a string, so the scheme pin is the
|
||||
// thing standing between a stored value and the DOM. Same contract as REST.
|
||||
it('refuses an image_url outside the four allowed shapes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_place',
|
||||
arguments: { tripId: trip.id, name: 'Hostile', image_url: 'javascript:alert(1)' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as { text: string }[])[0].text).toMatch(/Invalid arguments/);
|
||||
});
|
||||
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('broadcasts place:created event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -195,6 +247,61 @@ describe('Tool: update_place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets image_url on an existing place (#37)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Unpictured' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, image_url: 'https://cdn.example.com/new.jpg' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(imageOf(place.id)).toBe('https://cdn.example.com/new.jpg');
|
||||
});
|
||||
|
||||
// The service reads image_url through a `!== undefined` sentinel, so null is the
|
||||
// only way to say "remove the picture" - an omitted field keeps the old one.
|
||||
it('clears image_url when null is passed, and leaves it alone when omitted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Pictured' });
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/places/old.jpg', place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Renamed' } });
|
||||
expect(imageOf(place.id)).toBe('/uploads/places/old.jpg');
|
||||
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, image_url: null },
|
||||
});
|
||||
expect((parseToolResult(result) as any).place.image_url).toBeNull();
|
||||
});
|
||||
|
||||
expect(imageOf(place.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses an image_url outside the four allowed shapes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Pictured' });
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('https://cdn.example.com/keep.jpg', place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, image_url: 'http://insecure.example.com/x.jpg' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as { text: string }[])[0].text).toMatch(/Invalid arguments/);
|
||||
});
|
||||
|
||||
expect(imageOf(place.id)).toBe('https://cdn.example.com/keep.jpg');
|
||||
});
|
||||
|
||||
it('returns error for place not found in trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -252,6 +359,45 @@ describe('Tool: bulk_update_places', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the same image_url on every listed place (#37)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const b = createPlace(testDb, trip.id, { name: 'B' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_update_places',
|
||||
arguments: { tripId: trip.id, placeIds: [a.id, b.id], image_url: 'https://cdn.example.com/batch.jpg' },
|
||||
});
|
||||
expect((parseToolResult(result) as any).count).toBe(2);
|
||||
});
|
||||
|
||||
expect(imageOf(a.id)).toBe('https://cdn.example.com/batch.jpg');
|
||||
expect(imageOf(b.id)).toBe('https://cdn.example.com/batch.jpg');
|
||||
});
|
||||
|
||||
// A lone null still counts as a field, which is what makes "strip the pictures
|
||||
// off these eighty places" reachable in one call.
|
||||
it('strips the pictures off a batch when image_url is null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id, { name: 'A' });
|
||||
const b = createPlace(testDb, trip.id, { name: 'B' });
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id IN (?, ?)').run('https://cdn.example.com/old.jpg', a.id, b.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_update_places',
|
||||
arguments: { tripId: trip.id, placeIds: [a.id, b.id], image_url: null },
|
||||
});
|
||||
expect((parseToolResult(result) as any).count).toBe(2);
|
||||
});
|
||||
|
||||
expect(imageOf(a.id)).toBeNull();
|
||||
expect(imageOf(b.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('errors when no update fields are provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -380,6 +526,20 @@ describe('Tool: create_and_assign_place', () => {
|
||||
expect(skeleton.title).toBe('Fresh POI');
|
||||
});
|
||||
});
|
||||
|
||||
it('persists image_url on the place it creates (#37)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = parseToolResult(await h.client.callTool({
|
||||
name: 'create_and_assign_place',
|
||||
arguments: { tripId: trip.id, dayId: day.id, name: 'Pictured', image_url: 'https://cdn.example.com/day.jpg' },
|
||||
})) as any;
|
||||
expect(imageOf(result.place.id)).toBe('https://cdn.example.com/day.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -399,7 +559,7 @@ describe('Tool: search_place', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower');
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower', undefined, undefined);
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].osm_id).toBe('node:12345');
|
||||
expect(data.places[0].name).toBe('Eiffel Tower');
|
||||
@@ -419,7 +579,7 @@ describe('Tool: search_place', () => {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower');
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower', undefined, undefined);
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].google_place_id).toBe('ChIJD3uTd9hx5kcR1IQvGfr8dbk');
|
||||
expect(data.places[0].name).toBe('Eiffel Tower');
|
||||
@@ -436,6 +596,57 @@ describe('Tool: search_place', () => {
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Without the bias a bare name resolves wherever the provider guesses, which is
|
||||
// how "Central Station" used to come back from the wrong continent (#32).
|
||||
it('forwards locationBias and lang to the provider', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
searchPlacesMock.mockResolvedValue({ source: 'google', places: [] });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'search_place',
|
||||
arguments: {
|
||||
query: 'Central Station',
|
||||
locationBias: { lat: 35.6812, lng: 139.7671, radius: 8000 },
|
||||
lang: 'ja',
|
||||
},
|
||||
});
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
'Central Station',
|
||||
'ja',
|
||||
{ lat: 35.6812, lng: 139.7671, radius: 8000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a bias without an explicit radius', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
searchPlacesMock.mockResolvedValue({ source: 'google', places: [] });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'search_place',
|
||||
arguments: { query: 'Museum of Modern Art', locationBias: { lat: 40.7614, lng: -73.9776 } },
|
||||
});
|
||||
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Museum of Modern Art', undefined, { lat: 40.7614, lng: -73.9776 });
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a locationBias that is missing a coordinate', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
searchPlacesMock.mockResolvedValue({ source: 'google', places: [] });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'search_place',
|
||||
arguments: { query: 'Anywhere', locationBias: { lat: 48.8584 } },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(searchPlacesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -679,7 +890,7 @@ describe('Tool: import_places_from_url', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data).toMatchObject({ count: 2, listName: 'Weekend', skipped: 3 });
|
||||
expect(spy).toHaveBeenCalledWith(String(trip.id), 'https://maps.app.goo.gl/x');
|
||||
expect(spy).toHaveBeenCalledWith(String(trip.id), 'https://maps.app.goo.gl/x', { enrich: false, userId: user.id });
|
||||
expect(broadcastMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
spy.mockRestore();
|
||||
@@ -701,6 +912,58 @@ describe('Tool: import_places_from_url', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
// enrichImportedList guards on `opts?.enrich && opts.userId`, so without the
|
||||
// third argument the enrichment branch was structurally unreachable (#38).
|
||||
it('passes enrich and the calling user through to the google importer', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const spy = vi.spyOn(PlacesService.prototype, 'importGoogleList').mockResolvedValue({
|
||||
places: [], listName: 'Weekend', skipped: 0,
|
||||
});
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'import_places_from_url',
|
||||
arguments: { tripId: trip.id, url: 'https://maps.app.goo.gl/x', source: 'google-list', enrich: true },
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(String(trip.id), 'https://maps.app.goo.gl/x', { enrich: true, userId: user.id });
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('passes enrich through to the naver importer too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const spy = vi.spyOn(PlacesService.prototype, 'importNaverList').mockResolvedValue({
|
||||
places: [], listName: 'Seoul', skipped: 0,
|
||||
});
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'import_places_from_url',
|
||||
arguments: { tripId: trip.id, url: 'https://naver.me/x', source: 'naver-list', enrich: true },
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(String(trip.id), 'https://naver.me/x', { enrich: true, userId: user.id });
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('refuses a non-boolean enrich', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const spy = vi.spyOn(PlacesService.prototype, 'importGoogleList');
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'import_places_from_url',
|
||||
arguments: { tripId: trip.id, url: 'https://maps.app.goo.gl/x', source: 'google-list', enrich: 'yes' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
@@ -715,6 +978,103 @@ describe('Tool: import_places_from_url', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export_trip_gpx (#39)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: export_trip_gpx', () => {
|
||||
it('writes the trip places as waypoints and names the file after the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' });
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as { gpx: string; filename: string };
|
||||
expect(data.filename).toBe('Paris-Weekend.gpx');
|
||||
expect(data.gpx).toContain('<wpt');
|
||||
expect(data.gpx).toContain('Eiffel Tower');
|
||||
expect(data.gpx).toContain('48.8584');
|
||||
});
|
||||
});
|
||||
|
||||
it('writes each planned day as a route through its stops in order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' });
|
||||
const first = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
||||
const second = createPlace(testDb, trip.id, { name: 'Notre Dame', lat: 48.8530, lng: 2.3499 });
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDayAssignment(testDb, day.id, first.id);
|
||||
createDayAssignment(testDb, day.id, second.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as { gpx: string };
|
||||
expect(data.gpx).toContain('<rte>');
|
||||
expect(data.gpx.indexOf('Louvre')).toBeLessThan(data.gpx.indexOf('Notre Dame'));
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the three selection flags', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' });
|
||||
const first = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
||||
const second = createPlace(testDb, trip.id, { name: 'Notre Dame', lat: 48.8530, lng: 2.3499 });
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDayAssignment(testDb, day.id, first.id);
|
||||
createDayAssignment(testDb, day.id, second.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'export_trip_gpx',
|
||||
arguments: { tripId: trip.id, waypoints: false, tracks: false },
|
||||
});
|
||||
const data = parseToolResult(result) as { gpx: string };
|
||||
expect(data.gpx).not.toContain('<wpt');
|
||||
expect(data.gpx).toContain('<rte>');
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when every export type is switched off', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Weekend' });
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'export_trip_gpx',
|
||||
arguments: { tripId: trip.id, waypoints: false, tracks: false, dayRoutes: false },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as { text: string }[])[0].text).toBe('No export types selected.');
|
||||
});
|
||||
});
|
||||
|
||||
// An empty document imports as nothing on the other end, so say so instead.
|
||||
it('errors when the trip has nothing to write', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Empty' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result.content as { text: string }[])[0].text).toBe('Nothing to export.');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for a non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
createPlace(testDb, trip.id, { name: 'Theirs' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_gpx', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_and_assign_place failure path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Unit tests for MCP tag, maps extras, and weather tools:
|
||||
* list_tags, create_tag, update_tag, delete_tag,
|
||||
* get_place_details, reverse_geocode, resolve_maps_url,
|
||||
* get_place_details, search_pois, reverse_geocode, resolve_maps_url,
|
||||
* get_weather, get_detailed_weather.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -56,6 +56,21 @@ vi.spyOn(MapsService.prototype, 'getPlaceDetails').mockResolvedValue({
|
||||
name: 'Eiffel Tower',
|
||||
address: 'Paris',
|
||||
} as never);
|
||||
vi.spyOn(MapsService.prototype, 'getPlaceDetailsExpanded').mockResolvedValue({
|
||||
name: 'Eiffel Tower',
|
||||
summary: 'Wrought-iron lattice tower.',
|
||||
reviews: [{ author: 'Someone', rating: 5, text: 'Tall.', time: 'a month ago', photo: null }],
|
||||
} as never);
|
||||
// Overpass is stubbed one level below the facade so MapsService.pois() itself
|
||||
// still runs, the way the geo tools reach it.
|
||||
vi.spyOn(MapsService.prototype, 'searchOverpassPois').mockResolvedValue({
|
||||
pois: [{ osm_id: 'node:1', name: 'Chez Nous', lat: 48.86, lng: 2.34, category: 'restaurant' }],
|
||||
source: 'openstreetmap',
|
||||
truncated: false,
|
||||
clamped: false,
|
||||
} as never);
|
||||
// Off by default, so the existing cases exercise the lookup rather than the gate.
|
||||
vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(false);
|
||||
vi.spyOn(MapsService.prototype, 'reverseGeocode').mockResolvedValue({ name: 'Paris', address: 'France' });
|
||||
vi.spyOn(MapsService.prototype, 'resolveGoogleMapsUrl').mockResolvedValue({
|
||||
lat: 48.8566,
|
||||
@@ -316,6 +331,155 @@ describe('Tool: get_place_details', () => {
|
||||
// The former "isError when service returns null" case pinned a dead branch —
|
||||
// MapsService.getPlaceDetails throws or returns an object, never null — and
|
||||
// died with the guard in the fix(maps) quirk pass.
|
||||
|
||||
// The expanded field mask bills as a Google Enterprise SKU, so the default has
|
||||
// to stay on the lean lookup: nothing an existing caller does may start costing
|
||||
// more (#31).
|
||||
it('takes the lean path when expand is not asked for', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetails).mockClear();
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'get_place_details', arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' } });
|
||||
expect(MapsService.prototype.getPlaceDetails).toHaveBeenCalledWith(user.id, 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', 'en');
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('explicitly passing expand: false also stays on the lean path', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', expand: false, refresh: true },
|
||||
});
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('expand routes to the expanded lookup and returns reviews and the summary', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetails).mockClear();
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', expand: true, lang: 'de' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.details.summary).toBe('Wrought-iron lattice tower.');
|
||||
expect(data.details.reviews).toHaveLength(1);
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).toHaveBeenCalledWith(user.id, 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', 'de', false);
|
||||
expect(MapsService.prototype.getPlaceDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards refresh so a stale expanded payload can be re-fetched', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', expand: true, refresh: true },
|
||||
});
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).toHaveBeenCalledWith(user.id, 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', 'en', true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a non-boolean expand', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', expand: 'yes' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search_pois (#30)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: search_pois', () => {
|
||||
const BBOX = { south: 48.85, west: 2.33, north: 48.87, east: 2.36 };
|
||||
|
||||
it('returns the POIs of a category inside the bbox', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'search_pois',
|
||||
arguments: { category: 'restaurant', bbox: BBOX, lang: 'fr' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.pois).toHaveLength(1);
|
||||
expect(data.pois[0].name).toBe('Chez Nous');
|
||||
expect(data.source).toBe('openstreetmap');
|
||||
expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('restaurant', BBOX, 'fr');
|
||||
});
|
||||
});
|
||||
|
||||
it('passes no language through when none is given', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'search_pois', arguments: { category: 'museum', bbox: BBOX } });
|
||||
expect(MapsService.prototype.searchOverpassPois).toHaveBeenCalledWith('museum', BBOX, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// The enum is built from CATEGORY_OSM_FILTERS, so a category with no OSM tag
|
||||
// mapping never reaches Overpass in the first place.
|
||||
it('refuses a category that has no OSM mapping', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'search_pois',
|
||||
arguments: { category: 'dentist', bbox: BBOX },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a bbox edge outside the coordinate range', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'search_pois',
|
||||
arguments: { category: 'cafe', bbox: { ...BBOX, north: 118 } },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(MapsService.prototype.searchOverpassPois).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('answers isError when every Overpass mirror is unreachable', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(MapsService.prototype.searchOverpassPois).mockRejectedValueOnce(new Error('all mirrors failed'));
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_pois', arguments: { category: 'bar', bbox: BBOX } });
|
||||
expect(result.isError).toBe(true);
|
||||
expect((result as { content: { text: string }[] }).content[0].text).toBe('POI search failed.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -507,3 +671,67 @@ describe('Tool: get_airport', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The admin kill switch
|
||||
//
|
||||
// An instance owner turns Place Details off to stop Google billing them. REST
|
||||
// checks detailsDisabled() before it can reach either lookup; a tool that did
|
||||
// not would bill them from the one surface the switch does not cover, and the
|
||||
// expanded mask would do it at Enterprise-SKU rates.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_place_details (admin kill switch)', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(MapsService.prototype.detailsDisabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('fetches nothing when an admin has turned Place Details off', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(true);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetails).mockClear();
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.disabled).toBe(true);
|
||||
expect(data.details).toBeNull();
|
||||
expect(MapsService.prototype.getPlaceDetails).not.toHaveBeenCalled();
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('the switch also stops the expensive expanded path', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(true);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetailsExpanded).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ', expand: true },
|
||||
});
|
||||
expect((parseToolResult(result) as any).disabled).toBe(true);
|
||||
expect(MapsService.prototype.getPlaceDetailsExpanded).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the lookup alone while the switch is on', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.spyOn(MapsService.prototype, 'detailsDisabled').mockReturnValue(false);
|
||||
vi.mocked(MapsService.prototype.getPlaceDetails).mockClear();
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
|
||||
});
|
||||
expect((parseToolResult(result) as any).details.name).toBe('Eiffel Tower');
|
||||
expect(MapsService.prototype.getPlaceDetails).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user