mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-14 09:25:59 +00:00
feat(mcp): add bulk_update_places tool
Apply the same field values to many places in one call instead of one update_place per place — e.g. re-categorising 80 POIs at once. Adds the updatePlacesMany service (one transaction, trip-scoped, partial patch built on updatePlace) and the bulk_update_places MCP tool with the usual demo/access/place_edit guards and a place:updated broadcast per place.
This commit is contained in:
@@ -197,6 +197,64 @@ describe('Tool: update_place', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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('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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
|
||||
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
|
||||
@@ -233,6 +233,49 @@ describe('updatePlace', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePlacesMany ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updatePlacesMany', () => {
|
||||
it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
|
||||
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
|
||||
const c = createPlace(testDb, trip.id, { name: 'C' }) as any;
|
||||
|
||||
const updated = updatePlacesMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' });
|
||||
|
||||
expect(updated).toHaveLength(3);
|
||||
for (const p of updated) {
|
||||
expect((p as any).notes).toBe('visited');
|
||||
expect((p as any).transport_mode).toBe('walking');
|
||||
}
|
||||
// Only the provided fields change — names are untouched.
|
||||
expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any;
|
||||
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
|
||||
|
||||
const updated = updatePlacesMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' });
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect((updated[0] as any).id).toBe(mine.id);
|
||||
// The place from the other trip stays untouched.
|
||||
expect((getPlace(String(other.id), String(foreign.id)) as any).notes).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-041 — returns [] for an empty id list', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(updatePlacesMany(String(trip.id), [], { notes: 'x' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deletePlace ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deletePlace', () => {
|
||||
|
||||
Reference in New Issue
Block a user