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:
Maurice
2026-06-29 22:09:26 +02:00
committed by Maurice
parent 23987c76bb
commit 9dd9057b7b
5 changed files with 168 additions and 2 deletions
@@ -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', () => {