/** * Unit tests for MCP place tools: create_place, update_place, delete_place, search_place. * (list_categories moved to tools-categories.test.ts with the CategoriesMcp migration.) * * Since the place DI fold these run through the decorator-driven PlacesMcp in * src/nest/places/places.mcp.ts — the harness attaches it via the hand-wired * registry in tests/helpers/mcp-test-controllers.ts, so the assertions below * exercise the @Tool/@ResourceTemplate path instead of the deleted registrar. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA busy_timeout = 5000'); const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: (placeId: number) => { const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId); if (!place) return null; const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; }, canAccessTrip: (tripId: any, userId: number) => db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return { testDb: db, dbMock: mock }; }); vi.mock('../../../src/db/database', () => dbMock); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() })); // PlacesMcp and PlacesService both inject MapsService (search_place and the // import enrichment), so the geo calls are stubbed on the prototype — see // beforeEach — rather than through a module mock. import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createDay, createDayAssignment, createJourney } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { PlacesService } from '../../../src/nest/places/places.service'; /** Link a journey to a trip so journey-skeleton sync has a target. */ function linkJourney(journeyId: number, tripId: number) { testDb.prepare('INSERT INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)').run(journeyId, tripId, Date.now()); } 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); runMigrations(testDb); }); beforeEach(() => { resetTestDb(testDb); broadcastMock.mockClear(); searchPlacesMock.mockClear(); vi.spyOn(MapsService.prototype, 'searchPlaces').mockImplementation(searchPlacesMock as never); delete process.env.DEMO_MODE; }); afterAll(() => { testDb.close(); }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } // --------------------------------------------------------------------------- // create_place // --------------------------------------------------------------------------- describe('Tool: create_place', () => { it('creates a place with all fields', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number }; await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: 'Champ de Mars, Paris', category_id: cat.id, notes: 'Must visit', website: 'https://toureiffel.paris', phone: '+33 892 70 12 39', }, }); const data = parseToolResult(result) as any; expect(data.place.name).toBe('Eiffel Tower'); expect(data.place.lat).toBeCloseTo(48.8584); expect(data.place.category_id).toBe(cat.id); }); }); it('creates a place with minimal fields', 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: 'Mystery Spot' }, }); const data = parseToolResult(result) as any; expect(data.place.name).toBe('Mystery Spot'); expect(data.place.trip_id).toBe(trip.id); }); }); 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); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Cafe' } }); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:created', expect.any(Object)); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Hack' } }); expect(result.isError).toBe(true); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); 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: 'X' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // update_place // --------------------------------------------------------------------------- describe('Tool: update_place', () => { it('updates specific fields and preserves others', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Old Name' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'New Name' }, }); const data = parseToolResult(result) as any; expect(data.place.name).toBe('New Name'); // lat/lng preserved from original expect(data.place.lat).toBeCloseTo(place.lat ?? 48.8566); }); }); it('broadcasts place:updated event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Updated' } }); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object)); }); }); it('refuses a misspelt field instead of silently updating nothing', async () => { // An assistant wrote stopType for stop_type; the call used to come back as a // success with the place untouched, and the road trip never got its fuel stop. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Aral Bad Segeberg' }); await withHarness(user.id, async (h) => { const refused = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stopType: 'fuel' } }); expect(refused.isError).toBe(true); expect((refused.content as Array<{ text: string }>)[0].text).toMatch(/Unrecognized key.*stopType/); expect((testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id) as { stop_type: string | null }).stop_type).toBeNull(); expect(broadcastMock).not.toHaveBeenCalled(); const fixed = parseToolResult(await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stop_type: 'fuel' } })) as any; expect(fixed.place.stop_type).toBe('fuel'); }); }); 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); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: 99999 } }); expect(result.isError).toBe(true); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'X' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // bulk_update_places // --------------------------------------------------------------------------- describe('Tool: bulk_update_places', () => { it('applies the same field to many places in one call', 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], transport_mode: 'walking' }, }); const data = parseToolResult(result) as any; expect(data.count).toBe(2); expect([...data.updatedIds].sort()).toEqual([a.id, b.id].sort()); expect(data.skipped).toBe(0); }); }); it('broadcasts place:updated for each updated place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const a = createPlace(testDb, trip.id); const b = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { broadcastMock.mockClear(); await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id, b.id], notes: 'seen' } }); const updates = broadcastMock.mock.calls.filter((c) => c[1] === 'place:updated'); expect(updates).toHaveLength(2); }); }); 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); const a = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id] } }); expect(result.isError).toBe(true); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [place.id], notes: 'x' } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // delete_place // --------------------------------------------------------------------------- describe('Tool: delete_place', () => { it('deletes an existing place', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(place.id)).toBeUndefined(); }); }); it('takes the linked expense with it and announces that too (#1298)', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); const itemId = Number(testDb .prepare("INSERT INTO budget_items (trip_id, name, total_price, place_id) VALUES (?, 'Tickets', 34, ?)") .run(trip.id, place.id).lastInsertRowid); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(itemId)).toBeUndefined(); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.objectContaining({ itemId })); }); }); it('broadcasts place:deleted event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', expect.any(Object)); }); }); it('returns error for place not found', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: 99999 } }); expect(result.isError).toBe(true); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); const place = createPlace(testDb, trip.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); expect(result.isError).toBe(true); }); }); it('removes the linked journey skeleton when the place is deleted', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const place = createPlace(testDb, trip.id); createDayAssignment(testDb, day.id, place.id); const journey = createJourney(testDb, user.id); linkJourney(journey.id, trip.id); // Materialise the skeleton for the assigned place. testDb.prepare( `INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 'skeleton', ?, ?, 0, ?, ?)`, ).run(journey.id, trip.id, place.id, user.id, place.name, '2026-05-01', Date.now(), Date.now()); expect(skeletonFor(journey.id, place.id)).toBeDefined(); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); expect(skeletonFor(journey.id, place.id)).toBeUndefined(); }); }); }); // --------------------------------------------------------------------------- // create_and_assign_place // --------------------------------------------------------------------------- describe('Tool: create_and_assign_place', () => { it('creates a skeleton suggestion in a linked journey', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const journey = createJourney(testDb, user.id); linkJourney(journey.id, 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: 'Fresh POI' } }), ) as any; const skeleton = skeletonFor(journey.id, result.place.id); expect(skeleton).toBeDefined(); expect(skeleton.type).toBe('skeleton'); 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'); }); }); }); // --------------------------------------------------------------------------- // search_place // --------------------------------------------------------------------------- describe('Tool: search_place', () => { it('returns OSM results when no Google key is configured', async () => { const { user } = createUser(testDb); searchPlacesMock.mockResolvedValue({ source: 'openstreetmap', places: [ { osm_id: 'node:12345', name: 'Eiffel Tower', address: 'Eiffel Tower, Paris, France', lat: 48.8584, lng: 2.2945 }, ], }); 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', undefined, undefined, { googleOnly: false }); expect(data.places).toHaveLength(1); expect(data.places[0].osm_id).toBe('node:12345'); expect(data.places[0].name).toBe('Eiffel Tower'); expect(data.places[0].lat).toBeCloseTo(48.8584); }); }); it('returns google_place_id when Google Maps is configured', async () => { const { user } = createUser(testDb); searchPlacesMock.mockResolvedValue({ source: 'google', places: [ { google_place_id: 'ChIJD3uTd9hx5kcR1IQvGfr8dbk', name: 'Eiffel Tower', address: 'Champ de Mars, Paris', lat: 48.8584, lng: 2.2945, rating: 4.7, website: 'https://toureiffel.paris', phone: null }, ], }); 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', undefined, undefined, { googleOnly: false }); expect(data.places).toHaveLength(1); expect(data.places[0].google_place_id).toBe('ChIJD3uTd9hx5kcR1IQvGfr8dbk'); expect(data.places[0].name).toBe('Eiffel Tower'); expect(data.places[0].rating).toBe(4.7); }); }); it('returns error when place search fails', async () => { const { user } = createUser(testDb); searchPlacesMock.mockRejectedValue(new Error('Search failed')); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'something' } }); 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 }, { googleOnly: false }, ); }); }); 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 }, { googleOnly: false }); }); }); it('sends one search to Google alone when asked to, and refuses any other 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: 'Tokyo Station', provider: 'google' } }); expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Tokyo Station', undefined, undefined, { googleOnly: true }); searchPlacesMock.mockClear(); const refused = await h.client.callTool({ name: 'search_place', arguments: { query: 'Tokyo Station', provider: 'osm' } }); expect(refused.isError).toBe(true); expect(searchPlacesMock).not.toHaveBeenCalled(); }); }); 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(); }); }); }); // --------------------------------------------------------------------------- // list_places // --------------------------------------------------------------------------- describe('Tool: list_places', () => { it('returns all places by default', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' }); const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' }); const day = createDay(testDb, trip.id); testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } }); const data = parseToolResult(result) as any; expect(data.places).toHaveLength(2); }); }); it('returns only unassigned places with assignment=unassigned', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' }); const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' }); const day = createDay(testDb, trip.id); testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } }); const data = parseToolResult(result) as any; expect(data.places).toHaveLength(1); expect(data.places[0].name).toBe('Orphan Place'); }); }); it('returns only assigned places with assignment=assigned', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' }); const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' }); const day = createDay(testDb, trip.id); testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } }); const data = parseToolResult(result) as any; expect(data.places).toHaveLength(1); expect(data.places[0].name).toBe('Assigned Place'); }); }); it('returns empty array when all places are assigned', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Only Place' }); const day = createDay(testDb, trip.id); testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } }); const data = parseToolResult(result) as any; expect(data.places).toHaveLength(0); }); }); it('composes with search filter', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' }); const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); const day = createDay(testDb, trip.id); testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } }); const data = parseToolResult(result) as any; expect(data.places).toHaveLength(1); expect(data.places[0].name).toBe('Louvre Museum'); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // rate_place (#1435) // --------------------------------------------------------------------------- describe('Tool: rate_place', () => { it('stores the acting user\'s vote and reports the aggregate', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Rated' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 4 } }); const data = parseToolResult(result) as any; expect(data.place.id).toBe(place.id); const rows = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(place.id); expect(rows).toEqual([{ user_id: user.id, rating: 4 }]); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object)); }); }); it('an omitted rating clears the vote', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const place = createPlace(testDb, trip.id, { name: 'Rated' }); await withHarness(user.id, async (h) => { await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 5 } }); const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id } }); const data = parseToolResult(result) as any; expect(data.place.id).toBe(place.id); const rows = testDb.prepare('SELECT COUNT(*) AS n FROM place_ratings WHERE place_id = ?').get(place.id) as { n: number }; expect(rows.n).toBe(0); }); }); it('returns error for a place outside the trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const other = createTrip(testDb, user.id); const place = createPlace(testDb, other.id, { name: 'Elsewhere' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'rate_place', arguments: { tripId: trip.id, placeId: place.id, rating: 3 } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // bulk_delete_places // --------------------------------------------------------------------------- describe('Tool: bulk_delete_places', () => { it('deletes the trip-scoped ids, skips foreign ones and broadcasts each', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const other = createTrip(testDb, user.id); const a = createPlace(testDb, trip.id, { name: 'A' }); const b = createPlace(testDb, trip.id, { name: 'B' }); const foreign = createPlace(testDb, other.id, { name: 'Foreign' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [a.id, b.id, foreign.id] }, }); const data = parseToolResult(result) as any; expect(data.count).toBe(2); expect(data.deleted.sort()).toEqual([a.id, b.id].sort()); expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', { placeId: a.id, _source: 'mcp' }); // The other trip's place survives. expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeTruthy(); }); }); it('returns access denied for non-member', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); const place = createPlace(testDb, trip.id, { name: 'Theirs' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [place.id] } }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // trek://trips/{tripId}/places resource (moved from resources.test.ts) // --------------------------------------------------------------------------- describe('Resource: trek://trips/{tripId}/places', () => { it('returns all places for a trip', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); createPlace(testDb, trip.id, { name: 'Louvre' }); await withHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/places` }); const places = parseResourceResult(result) as any[]; expect(places).toHaveLength(2); const names = places.map((p) => p.name); expect(names).toContain('Eiffel Tower'); expect(names).toContain('Louvre'); }); }); // The ?assignment= filter the description advertises is read off // uri.searchParams inside the handler, but the SDK's template matcher rejects // a URI carrying a query string before the handler runs — same as the legacy // resource this was moved from, so it stays untested here. it('returns access denied for unauthorized trip', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const trip = createTrip(testDb, other.id); await withHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/places` }); const data = parseResourceResult(result) as any; expect(data.error).toBeTruthy(); }); }); }); // --------------------------------------------------------------------------- // import_places_from_url // --------------------------------------------------------------------------- describe('Tool: import_places_from_url', () => { it('imports a google list, broadcasts each place and reports the count', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const spy = vi.spyOn(PlacesService.prototype, 'importGoogleList').mockResolvedValue({ places: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] as never, listName: 'Weekend', skipped: 3, }); 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' }, }); 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', { enrich: false, userId: user.id }); expect(broadcastMock).toHaveBeenCalledTimes(2); }); spy.mockRestore(); }); it('sends a directions link to the directions importer, the same way the REST route does', async () => { // The parity that matters here is not the wire shape but the dispatch: a pasted // route has to reach the same importer through the tool as through the box. const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const dir = vi.spyOn(PlacesService.prototype, 'importGoogleDirections').mockResolvedValue({ places: [{ id: 7, name: 'Berlin' }] as never, listName: 'Berlin \u2192 Prague', skipped: 1, }); const list = vi.spyOn(PlacesService.prototype, 'importGoogleList'); await withHarness(user.id, async (h) => { const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'; const result = await h.client.callTool({ name: 'import_places_from_url', arguments: { tripId: trip.id, url, source: 'google-list' }, }); expect(parseToolResult(result) as any).toMatchObject({ count: 1, skipped: 1 }); expect(dir).toHaveBeenCalledWith(String(trip.id), url, { enrich: false, userId: user.id }); expect(list).not.toHaveBeenCalled(); }); dir.mockRestore(); list.mockRestore(); }); it('surfaces the naver importer\'s { error, status } as a tool error', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const spy = vi.spyOn(PlacesService.prototype, 'importNaverList').mockResolvedValue({ error: 'List is empty or could not be read', status: 400 }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'import_places_from_url', arguments: { tripId: trip.id, url: 'https://naver.me/x', source: 'naver-list' }, }); expect(result.isError).toBe(true); expect((result.content as { text: string }[])[0].text).toBe('List is empty or could not be read'); }); 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); const trip = createTrip(testDb, other.id); 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' }, }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // 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(' { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: '沖縄 4泊5日' }); createPlace(testDb, trip.id, { name: '首里城', lat: 26.217, lng: 127.719 }); 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('沖縄-4泊5日.gpx'); expect(data.gpx).toContain('首里城'); }); }); 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(''); 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(''); }); }); 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 // --------------------------------------------------------------------------- describe('Tool: create_and_assign_place (failure paths)', () => { it('reports a missing day without writing the place', 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_and_assign_place', arguments: { tripId: trip.id, dayId: 99999, name: 'Nowhere' }, }); expect(result.isError).toBe(true); expect((result.content as { text: string }[])[0].text).toBe('Day not found.'); expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 0 }); }); }); it('rolls the transaction back and reports failure when the place write throws', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); const spy = vi.spyOn(PlacesService.prototype, 'create').mockImplementation(() => { throw new Error('db exploded'); }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_and_assign_place', arguments: { tripId: trip.id, dayId: day.id, name: 'Doomed' }, }); expect(result.isError).toBe(true); expect((result.content as { text: string }[])[0].text).toBe('Failed to create place and assignment.'); expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 }); }); spy.mockRestore(); }); }); // --------------------------------------------------------------------------- // Journey-hook scoping + ordering on the delete paths (#1745) // --------------------------------------------------------------------------- /** Materialise a skeleton entry for an assigned place (same shape the sync writes). */ function seedSkeleton(journeyId: number, tripId: number, placeId: number, userId: number, name: string) { testDb.prepare( `INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 'skeleton', ?, ?, 0, ?, ?)`, ).run(journeyId, tripId, placeId, userId, name, '2026-05-01', Date.now(), Date.now()); } describe('journey hooks on the MCP delete paths', () => { it("delete_place leaves a foreign trip's journey skeleton alone", async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const other = createTrip(testDb, user.id); const journey = createJourney(testDb, user.id); linkJourney(journey.id, other.id); const foreign = createPlace(testDb, other.id, { name: 'Theirs' }); const day = createDay(testDb, other.id); createDayAssignment(testDb, day.id, foreign.id); seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs'); expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: foreign.id } }); expect(result.isError).toBe(true); }); // The other trip's skeleton entry still points at its place, and the row lives. expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(foreign.id)).toBeDefined(); }); // Without the ordering fix the hook ran AFTER the DELETE, by which point // journey_entries.source_place_id was already NULL (ON DELETE SET NULL) — so // the entry survived as an orphan instead of being removed. Asserting on the // entry count, not on source_place_id, is what tells the two apart. it('bulk_delete_places detaches the skeletons before the rows go', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const journey = createJourney(testDb, user.id); linkJourney(journey.id, trip.id); const place = createPlace(testDb, trip.id, { name: 'Doomed' }); const day = createDay(testDb, trip.id); createDayAssignment(testDb, day.id, place.id); seedSkeleton(journey.id, trip.id, place.id, user.id, 'Doomed'); expect(skeletonFor(journey.id, place.id)).toBeDefined(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [place.id] } }); expect((parseToolResult(result) as any).count).toBe(1); }); // The hook ran while the row still existed, so the entry is gone — not // lingering with a NULL source_place_id. expect(skeletonFor(journey.id, place.id)).toBeUndefined(); expect(testDb.prepare('SELECT COUNT(*) AS n FROM journey_entries WHERE journey_id = ?').get(journey.id)).toEqual({ n: 0 }); }); // The MCP bulk path already scoped correctly (it looped over removeMany's // trip-scoped result), so this is a guard against the scoping regressing when // the hook moved ahead of the delete — not a fix for a live bug. it('bulk_delete_places leaves a foreign trip\'s skeleton alone', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const other = createTrip(testDb, user.id); const journey = createJourney(testDb, user.id); linkJourney(journey.id, other.id); const foreign = createPlace(testDb, other.id, { name: 'Theirs' }); const day = createDay(testDb, other.id); createDayAssignment(testDb, day.id, foreign.id); seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'bulk_delete_places', arguments: { tripId: trip.id, placeIds: [foreign.id] } }); expect((parseToolResult(result) as any).count).toBe(0); }); expect(skeletonFor(journey.id, foreign.id)).toBeDefined(); }); }); // --------------------------------------------------------------------------- // A website without a scheme (#2483): the tools parse it the way REST does // --------------------------------------------------------------------------- describe('place tools and a website without a scheme (#2483)', () => { const SITE = 'fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët'; const websiteOf = (placeId: number) => (testDb.prepare('SELECT website FROM places WHERE id = ?').get(placeId) as { website: string | null }).website; it('MCP-PLACES-2483-01: create_place, create_and_assign_place, update_place and bulk_update_places store https', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const day = createDay(testDb, trip.id); await withHarness(user.id, async (h) => { const created = parseToolResult(await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Chapelle', website: SITE } })) as { place: { id: number } }; expect(websiteOf(created.place.id)).toBe(`https://${SITE}`); const assigned = parseToolResult(await h.client.callTool({ name: 'create_and_assign_place', arguments: { tripId: trip.id, dayId: day.id, name: 'Halles', website: 'www.example.fr/halles' }, })) as { place: { id: number } }; expect(websiteOf(assigned.place.id)).toBe('https://www.example.fr/halles'); await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: created.place.id, website: '//www.example.fr' } }); expect(websiteOf(created.place.id)).toBe('https://www.example.fr'); await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [created.place.id, assigned.place.id], website: 'example.fr:8080/x' } }); expect([websiteOf(created.place.id), websiteOf(assigned.place.id)]).toEqual(['https://example.fr:8080/x', 'https://example.fr:8080/x']); }); }); it('MCP-PLACES-2483-02: a script link or another scheme is still refused and stores nothing', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { for (const website of ['javascript:alert(1)', 'mailto:mairie@example.fr', 'Chapelle']) { const result = await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Hostile', website } }); expect(result.isError, website).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('MCP-PLACES-2483-03: tools/list still describes the field as a plain capped string', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const { tools } = await h.client.listTools(); for (const name of ['create_place', 'create_and_assign_place', 'update_place', 'bulk_update_places']) { const tool = tools.find((t) => t.name === name); expect((tool?.inputSchema.properties as Record).website, name).toEqual({ type: 'string', maxLength: 500 }); } }); }); });