mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1572 lines
62 KiB
TypeScript
1572 lines
62 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 through the booted
|
|
* container's registry (tests/helpers/mcp-test-controllers.ts), so the assertions below
|
|
* exercise the @Tool/@ResourceTemplate path instead of the deleted registrar.
|
|
*/
|
|
import { db as testDb } from '../../../src/db/database';
|
|
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';
|
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import { PlacesService } from '../../../src/nest/places/places.service';
|
|
import {
|
|
createUser,
|
|
createTrip,
|
|
createPlace,
|
|
createDay,
|
|
createDayAssignment,
|
|
createJourney,
|
|
} from '../../helpers/factories';
|
|
import { makeBudgetItem } from '../../helpers/factories/budget';
|
|
import { makeTripFile } from '../../helpers/factories/files';
|
|
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
|
import { linkTripToJourney } from '../../helpers/factories/journeys';
|
|
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
|
|
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
// 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 { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() }));
|
|
|
|
const realtime = new FakeRealtimeService();
|
|
const broadcastMock = realtime.broadcastMock;
|
|
|
|
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({ realtime, 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 refusal as a tool error", async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const spy = vi
|
|
.spyOn(PlacesService.prototype, 'importNaverList')
|
|
.mockRejectedValue(new DomainError(400, 'List is empty or could not be read'));
|
|
|
|
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.853, 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.853, 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,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|