mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
ddf97c5a21
The MCP tool suites seeded trips, places, bookings, packing, vacay and settings rows and read back what a tool call left behind through raw better-sqlite3 statements on the sync handle. They now go through the ORM factories and readers in tests/helpers/factories, so the rows are written and read the way the repositories do it. Every assertion keeps its meaning: a missing row that read back as undefined now reads back as null, and a partial SELECT is replaced by the same fields picked off the full row.
1389 lines
61 KiB
TypeScript
1389 lines
61 KiB
TypeScript
/**
|
|
* 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';
|
|
import { db as testDb } from '../../../src/db/database';
|
|
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
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 { 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';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { linkTripToJourney } from '../../helpers/factories/journeys';
|
|
import { makeTripFile } from '../../helpers/factories/files';
|
|
import { makeBudgetItem } from '../../helpers/factories/budget';
|
|
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
|
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
|
import { Categories } from '../../../src/db/entities/Categories.entity';
|
|
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
|
import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity';
|
|
import { PlaceRatings } from '../../../src/db/entities/PlaceRatings.entity';
|
|
import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
|
|
let orm: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(testDb);
|
|
});
|
|
|
|
/** Link a journey to a trip so journey-skeleton sync has a target. */
|
|
function linkJourney(journeyId: number, tripId: number) {
|
|
return linkTripToJourney(orm, journeyId, tripId);
|
|
}
|
|
function skeletonFor(journeyId: number, placeId: number) {
|
|
return findRow(orm, JourneyEntries, { journey: journeyId, sourcePlace: placeId });
|
|
}
|
|
/** The stored thumbnail, read off the row rather than out of the tool's echo. */
|
|
async function imageOf(placeId: number): Promise<string | null | undefined> {
|
|
return (await findRow(orm, Places, { id: placeId }))!.image_url;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
searchPlacesMock.mockClear();
|
|
vi.spyOn(MapsService.prototype, 'searchPlaces').mockImplementation(searchPlacesMock as never);
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await orm.close();
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
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 = (await findRow(orm, Categories, {}))!;
|
|
|
|
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 = (await findRow(orm, Places, { trip: trip.id, name: 'Pictured' }))!;
|
|
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 = (await findRow(orm, Places, { trip: trip.id, name: 'Proxied' }))!;
|
|
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(await countRows(orm, Places, { trip: trip.id })).toBe(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: set_place_image_from_file (#1242)', () => {
|
|
it('refuses a file that is no picture, and one from another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const other = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id);
|
|
const pdf = (await makeTripFile(orm, trip.id, { filename: 'a.pdf', original_name: 'a.pdf', mime_type: 'application/pdf' })).id;
|
|
const foreign = (await makeTripFile(orm, other.id, { filename: 'b.jpg', original_name: 'b.jpg', mime_type: 'image/jpeg' })).id;
|
|
await withHarness(user.id, async (h) => {
|
|
const notImage = await h.client.callTool({ name: 'set_place_image_from_file', arguments: { tripId: trip.id, placeId: place.id, fileId: pdf } });
|
|
expect(notImage.isError).toBe(true);
|
|
const notFound = await h.client.callTool({ name: 'set_place_image_from_file', arguments: { tripId: trip.id, placeId: place.id, fileId: foreign } });
|
|
expect(notFound.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
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('takes an e-mail and structured opening hours, and null clears the hours (#2472)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Bakery' });
|
|
const hours = [{ closed: false, open: '08:00', close: '18:00' }, ...Array.from({ length: 6 }, () => ({ closed: true }))];
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(await h.client.callTool({
|
|
name: 'update_place',
|
|
arguments: { tripId: trip.id, placeId: place.id, email: 'hi@bakery.test', opening_hours: hours },
|
|
})) as any;
|
|
expect(data.place.email).toBe('hi@bakery.test');
|
|
expect(JSON.parse(data.place.opening_hours)).toEqual(hours);
|
|
|
|
const cleared = parseToolResult(await h.client.callTool({
|
|
name: 'update_place',
|
|
arguments: { tripId: trip.id, placeId: place.id, opening_hours: null },
|
|
})) as any;
|
|
expect(cleared.place.opening_hours).toBeNull();
|
|
expect(cleared.place.email).toBe('hi@bakery.test');
|
|
|
|
const bad = await h.client.callTool({
|
|
name: 'update_place',
|
|
arguments: { tripId: trip.id, placeId: place.id, email: 'not-an-address' },
|
|
});
|
|
expect(bad.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('moving a place drops the country Atlas cached for it, renaming it does not (#2527)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Hotel', lat: 48.8566, lng: 2.3522 });
|
|
await insertRow(orm, PlaceRegions, { place: place.id, country_code: 'FR', region_code: 'FR-IDF', region_name: 'Ile-de-France' });
|
|
const cachedRegion = async () => {
|
|
const row = await findRow(orm, PlaceRegions, { place: place.id });
|
|
return row ? { country_code: row.country_code } : undefined;
|
|
};
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Hotel Adlon' } });
|
|
expect(await cachedRegion()).toEqual({ country_code: 'FR' });
|
|
|
|
const moved = await h.client.callTool({
|
|
name: 'update_place',
|
|
arguments: { tripId: trip.id, placeId: place.id, lat: 52.5163, lng: 13.3777, address: 'Unter den Linden 77, Berlin, Germany' },
|
|
});
|
|
expect(moved.isError).toBeFalsy();
|
|
expect(await cachedRegion()).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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((await findRow(orm, Places, { id: place.id }))!.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(await 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' });
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: '/uploads/places/old.jpg' });
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Renamed' } });
|
|
expect(await 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(await 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' });
|
|
await updateRows(orm, Places, { id: place.id }, { image_url: 'https://cdn.example.com/keep.jpg' });
|
|
|
|
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(await 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(await imageOf(a.id)).toBe('https://cdn.example.com/batch.jpg');
|
|
expect(await 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' });
|
|
await updateRows(orm, Places, { id: { $in: [a.id, b.id] } }, { image_url: 'https://cdn.example.com/old.jpg' });
|
|
|
|
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(await imageOf(a.id)).toBeNull();
|
|
expect(await 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(await findRow(orm, Places, { id: place.id })).toBeNull();
|
|
});
|
|
});
|
|
|
|
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 { id: itemId } = await makeBudgetItem(orm, trip.id, { name: 'Tickets', total_price: 34, place: place.id });
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
|
|
expect(await findRow(orm, BudgetItems, { id: itemId })).toBeNull();
|
|
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);
|
|
await linkJourney(journey.id, trip.id);
|
|
// Materialise the skeleton for the assigned place.
|
|
await seedSkeleton(journey.id, trip.id, place.id, user.id, place.name);
|
|
expect(await skeletonFor(journey.id, place.id)).not.toBeNull();
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
|
|
expect(await skeletonFor(journey.id, place.id)).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
await 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 = await skeletonFor(journey.id, result.place.id);
|
|
expect(skeleton).not.toBeNull();
|
|
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(await 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);
|
|
await makeDayAssignment(orm, 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);
|
|
await makeDayAssignment(orm, 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);
|
|
await makeDayAssignment(orm, 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);
|
|
await makeDayAssignment(orm, 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);
|
|
await makeDayAssignment(orm, 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 = (await findRows(orm, PlaceRatings, { place: place.id })).map(r => ({ user_id: r.user_id, rating: r.rating }));
|
|
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);
|
|
expect(await countRows(orm, PlaceRatings, { place: place.id })).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(await findRow(orm, Places, { id: 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('<wpt');
|
|
expect(data.gpx).toContain('Eiffel Tower');
|
|
expect(data.gpx).toContain('48.8584');
|
|
});
|
|
});
|
|
|
|
// Same gpxFilename as the REST route (#2165): the title's script survives in
|
|
// the filename now — the header folding is the controller's job, not ours.
|
|
it('keeps a non-ASCII trip title in the filename', async () => {
|
|
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('<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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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(await countRows(orm, Places, { trip: trip.id })).toBe(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(await countRows(orm, DayAssignments, { day: day.id })).toBe(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). */
|
|
async function seedSkeleton(journeyId: number, tripId: number, placeId: number, userId: number, name: string) {
|
|
await insertRow(orm, JourneyEntries, {
|
|
journey: journeyId, sourceTrip: tripId, sourcePlace: placeId, author: userId, type: 'skeleton', title: name,
|
|
entry_date: '2026-05-01', sort_order: 0, created_at: Date.now(), updated_at: 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);
|
|
await 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);
|
|
await seedSkeleton(journey.id, other.id, foreign.id, user.id, 'Theirs');
|
|
expect(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
|
|
|
|
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(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
|
|
expect(await findRow(orm, Places, { id: foreign.id })).not.toBeNull();
|
|
});
|
|
|
|
// 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);
|
|
await 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);
|
|
await seedSkeleton(journey.id, trip.id, place.id, user.id, 'Doomed');
|
|
expect(await skeletonFor(journey.id, place.id)).not.toBeNull();
|
|
|
|
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(await skeletonFor(journey.id, place.id)).toBeNull();
|
|
expect(await countRows(orm, JourneyEntries, { journey: journey.id })).toBe(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);
|
|
await 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);
|
|
await 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(await skeletonFor(journey.id, foreign.id)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = async (placeId: number) => (await findRow(orm, Places, { id: placeId }))!.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(await 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(await 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(await 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([await websiteOf(created.place.id), await 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(await countRows(orm, Places, { trip: trip.id })).toBe(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<string, unknown>).website, name).toEqual({ type: 'string', maxLength: 500 });
|
|
}
|
|
});
|
|
});
|
|
});
|