mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(realtime): place writes over MCP and plugins send what REST sends
This commit is contained in:
@@ -109,6 +109,8 @@ export class PlacesMcp {
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const place = await this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
|
||||
this.guards.safeBroadcast(tripId, 'place:created', { place });
|
||||
// The journey hook the REST route and the plugin RPC run after the same write.
|
||||
await this.places.onCreated(String(tripId), place.id);
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@@ -220,8 +222,11 @@ export class PlacesMcp {
|
||||
if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours: opening_hours === undefined ? undefined : hoursText(opening_hours), image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
// No base version is sent, so the update never answers a conflict; the guard only narrows the type.
|
||||
if (!place || isUpdateConflict(place)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
// As on REST: the journey entries made from this place follow its new name, address and time.
|
||||
await this.places.onUpdated(place.id);
|
||||
return ok({ place });
|
||||
}
|
||||
|
||||
@@ -248,6 +253,7 @@ export class PlacesMcp {
|
||||
if (result === 'too_large') return errorResult('That image is too large.');
|
||||
if (!result || isUpdateConflict(result)) return errorResult('Place not found.');
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place: result });
|
||||
await this.places.onUpdated(result.id);
|
||||
return ok({ place: result });
|
||||
}
|
||||
|
||||
@@ -521,7 +527,11 @@ export class PlacesMcp {
|
||||
}
|
||||
|
||||
const updated = await this.places.updateMany(String(tripId), placeIds, fields);
|
||||
for (const place of updated) this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
// Per place, the event and then the journey hook, in the order POST /places/bulk-update keeps.
|
||||
for (const place of updated) {
|
||||
this.guards.safeBroadcast(tripId, 'place:updated', { place });
|
||||
await this.places.onUpdated(place.id);
|
||||
}
|
||||
return ok({ count: updated.length, updatedIds: updated.map(p => p.id), skipped: placeIds.length - updated.length });
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
// PL33's `importKmlPlaces` folder → category lookup. Plan 3h Task 6 adds
|
||||
// `CollectionPlaces` for SV-PI2 (`reclaimPlaceImage`'s second existence
|
||||
// check) — a read-only cross-domain repository reference, not a
|
||||
// `CollectionsModule` import, so it creates no cycle (`CollectionsModule`
|
||||
// does not import `PlacesModule` either — both sides reach the other's
|
||||
// table only through `forFeature`).
|
||||
// `CollectionsModule` import, so it creates no cycle: `CollectionsModule`
|
||||
// imports this module for copyToTrip's journey hook, and this side reaches
|
||||
// the collections table only through `forFeature`.
|
||||
MikroOrmModule.forFeature([
|
||||
Places,
|
||||
Tags,
|
||||
|
||||
@@ -1,7 +1,93 @@
|
||||
import type { CaseGroup } from './types';
|
||||
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
|
||||
import { RoadtripDayBoundaries } from '../../../src/db/entities/RoadtripDayBoundaries.entity';
|
||||
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
|
||||
import type { ListImportResult } from '../../../src/nest/places/places.helpers';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
||||
import { insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import type { CaseGroup, Scene } from './types';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
const CATALOG = "the admin's category catalog is global: no trip room, and the registry has no event for it";
|
||||
|
||||
const placesPath = (s: Scene) => `/api/trips/${s.trip.id}/places`;
|
||||
const daysPath = (s: Scene) => `/api/trips/${s.trip.id}/days`;
|
||||
/** The assignments of one of the scene's days. */
|
||||
const dayPlanPath = (s: Scene, day: 0 | 1 | 2) => `/api/trips/${s.trip.id}/days/${s.days[day].id}/assignments`;
|
||||
/** The per-assignment routes of the scene's assignment. */
|
||||
const assignmentPath = (s: Scene) => `/api/trips/${s.trip.id}/assignments/${s.assignment.id}`;
|
||||
const dayNotesPath = (s: Scene) => `/api/trips/${s.trip.id}/days/${s.days[0].id}/notes`;
|
||||
const onAssignment = (s: Scene) => ({ tripId: s.trip.id, assignmentId: s.assignment.id });
|
||||
|
||||
/** One waypoint, the smallest file the importer turns into a place. */
|
||||
const GPX = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<gpx version="1.1" creator="TREK parity" xmlns="http://www.topografix.com/GPX/1/1">',
|
||||
'<wpt lat="48.8583" lon="2.3375"><name>Pont des Arts</name></wpt>',
|
||||
'</gpx>',
|
||||
].join('');
|
||||
|
||||
/**
|
||||
* A shared Google Maps list without the network: the list reader answers with
|
||||
* one place, written the way the importer writes it, so the route and the tool
|
||||
* announce a real row.
|
||||
*/
|
||||
function stubGoogleList(app: INestApplication): () => void {
|
||||
const places = app.get(PlacesService);
|
||||
const spy = vi
|
||||
.spyOn(places, 'importGoogleList')
|
||||
.mockImplementation(async (tripId: string): Promise<ListImportResult> => {
|
||||
const place = await places.create(tripId, { name: 'Shakespeare and Company', lat: 48.8526, lng: 2.3471 });
|
||||
return { places: [place], listName: 'Left bank', skipped: 0 };
|
||||
});
|
||||
return () => spy.mockRestore();
|
||||
}
|
||||
|
||||
/** The scene's file becomes a picture, with its bytes in storage, so a place can take it as its image. */
|
||||
async function fileIsPicture(s: Scene, app: INestApplication): Promise<void> {
|
||||
const name = `poster-${s.n}.jpg`;
|
||||
await app.get(StorageService).put('files', name, Readable.from(Buffer.from('jpg')), { contentType: 'image/jpeg' });
|
||||
await updateRows(
|
||||
app.get(MikroORM),
|
||||
TripFiles,
|
||||
{ id: s.file.id },
|
||||
{ filename: name, original_name: name, mime_type: 'image/jpeg', file_size: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
/** A second stop on the first day, behind the scene's assignment. */
|
||||
async function secondStop(s: Scene, app: INestApplication): Promise<void> {
|
||||
await makeDayAssignment(app.get(MikroORM), s.days[0].id, s.place2.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first day in time order, 07:00 then 08:00, with the scene's via after
|
||||
* the first stop: moving the first stop to 09:00 sorts the day and moves the
|
||||
* via with its stop.
|
||||
*/
|
||||
async function timedDay(s: Scene, app: INestApplication): Promise<void> {
|
||||
const orm = app.get(MikroORM);
|
||||
await updateRows(orm, DayAssignments, { id: s.assignment.id }, { assignment_time: '07:00' });
|
||||
await makeDayAssignment(orm, s.days[0].id, s.place2.id, { assignment_time: '08:00' });
|
||||
}
|
||||
|
||||
/** A road trip day boundary on the second day, after a stop of its own there. */
|
||||
async function boundaryOnSecondDay(s: Scene, app: INestApplication): Promise<void> {
|
||||
const orm = app.get(MikroORM);
|
||||
const stop = await makeDayAssignment(orm, s.days[1].id, s.place.id);
|
||||
await insertRow(orm, RoadtripDayBoundaries, {
|
||||
trip: s.trip.id,
|
||||
day_number: 2,
|
||||
fromAssignment: stop.id,
|
||||
fraction: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The planner: places, the day plan, days, day notes, categories, and the
|
||||
* collection places copied into a trip.
|
||||
@@ -15,7 +101,7 @@ export const plan: CaseGroup = {
|
||||
args: (s) => ({ tripId: s.trip.id, name: 'Sacré-Cœur', lat: 48.8867, lng: 2.3431 }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/places`,
|
||||
path: placesPath,
|
||||
body: () => ({ name: 'Sacré-Cœur', lat: 48.8867, lng: 2.3431 }),
|
||||
},
|
||||
expect: ['place:created'],
|
||||
@@ -46,6 +132,280 @@ export const plan: CaseGroup = {
|
||||
{ event: 'assignment:reordered', all: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
tool: 'create_and_assign_place',
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[1].id, name: 'Café de Flore', lat: 48.854, lng: 2.3326 }),
|
||||
twin: 'POST /api/trips/:tripId/places, then POST /api/trips/:tripId/days/:dayId/assignments',
|
||||
expect: ['place:created', 'assignment:created'],
|
||||
},
|
||||
{
|
||||
tool: 'update_place',
|
||||
needs: ['place'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.place.id, name: 'Musée du Louvre' }),
|
||||
rest: { method: 'put', path: (s) => `${placesPath(s)}/${s.place.id}`, body: () => ({ name: 'Musée du Louvre' }) },
|
||||
expect: ['place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'set_place_image_from_file',
|
||||
needs: ['place', 'file'],
|
||||
setup: fileIsPicture,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.place.id, fileId: s.file.id }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${placesPath(s)}/${s.place.id}/image/from-file`,
|
||||
body: (s) => ({ file_id: s.file.id }),
|
||||
},
|
||||
expect: ['place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'rate_place',
|
||||
needs: ['place'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.place.id, rating: 4 }),
|
||||
rest: { method: 'put', path: (s) => `${placesPath(s)}/${s.place.id}/rating`, body: () => ({ rating: 4 }) },
|
||||
expect: ['place:updated'],
|
||||
},
|
||||
{
|
||||
// The hotel of a stay: the night and its partner booking go with the place,
|
||||
// and the deleting tab hears that too, since it only removed the place.
|
||||
tool: 'delete_place',
|
||||
needs: ['stay'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.stay.placeId }),
|
||||
rest: { method: 'delete', path: (s) => `${placesPath(s)}/${s.stay.placeId}` },
|
||||
expect: ['place:deleted', { event: 'reservation:deleted', all: true }],
|
||||
},
|
||||
{
|
||||
tool: 'import_places_from_url',
|
||||
args: (s) => ({ tripId: s.trip.id, url: 'https://maps.app.goo.gl/parityList', source: 'google-list' }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `${placesPath(s)}/import/google-list`,
|
||||
body: () => ({ url: 'https://maps.app.goo.gl/parityList' }),
|
||||
},
|
||||
stub: stubGoogleList,
|
||||
expect: ['place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'import_trip_gpx',
|
||||
args: (s) => ({ tripId: s.trip.id, gpx: GPX, name: 'Paris walk', importRoutes: false, importTracks: false }),
|
||||
twin: 'POST /api/trips/:tripId/places/import/gpx, a multipart upload of the same file',
|
||||
expect: ['place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'bulk_delete_places',
|
||||
needs: ['place', 'place2'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeIds: [s.place.id, s.place2.id] }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `${placesPath(s)}/bulk-delete`,
|
||||
body: (s) => ({ ids: [s.place.id, s.place2.id] }),
|
||||
},
|
||||
expect: ['place:deleted', 'place:deleted'],
|
||||
},
|
||||
{
|
||||
// REST takes the category alone; the tool's other fields are not a difference in events.
|
||||
tool: 'bulk_update_places',
|
||||
needs: ['place', 'place2', 'category'],
|
||||
args: (s) => ({ tripId: s.trip.id, placeIds: [s.place.id, s.place2.id], category_id: s.category.id }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `${placesPath(s)}/bulk-update`,
|
||||
body: (s) => ({ ids: [s.place.id, s.place2.id], category_id: s.category.id }),
|
||||
},
|
||||
expect: ['place:updated', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'assign_place_to_day',
|
||||
needs: ['place'],
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[1].id, placeId: s.place.id }),
|
||||
rest: { method: 'post', path: (s) => dayPlanPath(s, 1), body: (s) => ({ place_id: s.place.id }) },
|
||||
expect: ['assignment:created'],
|
||||
},
|
||||
{
|
||||
tool: 'set_assignment_end_day',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), end_day: true }),
|
||||
rest: { method: 'put', path: (s) => `${assignmentPath(s)}/end-day`, body: () => ({ end_day: true }) },
|
||||
expect: ['assignment:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'unassign_place',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), dayId: s.days[0].id }),
|
||||
rest: { method: 'delete', path: (s) => `${dayPlanPath(s, 0)}/${s.assignment.id}` },
|
||||
expect: ['assignment:deleted'],
|
||||
},
|
||||
{
|
||||
tool: 'clear_day_assignments',
|
||||
needs: ['assignment', 'place2'],
|
||||
setup: secondStop,
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id }),
|
||||
rest: { method: 'delete', path: (s) => dayPlanPath(s, 0) },
|
||||
expect: ['assignment:deleted', 'assignment:deleted'],
|
||||
},
|
||||
{
|
||||
// A start that moves sorts the day: the new order and the vias pinned to it
|
||||
// reach the writing tab too, which routes on them before its reload.
|
||||
tool: 'update_assignment_time',
|
||||
needs: ['assignment', 'place2', 'via'],
|
||||
setup: timedDay,
|
||||
args: (s) => ({ ...onAssignment(s), place_time: '09:00' }),
|
||||
rest: { method: 'put', path: (s) => `${assignmentPath(s)}/time`, body: () => ({ place_time: '09:00' }) },
|
||||
expect: [
|
||||
'assignment:updated',
|
||||
{ event: 'assignment:reordered', all: true },
|
||||
{ event: 'roadtripVia:changed', all: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
tool: 'update_assignment_notes',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), notes: 'Tickets at the side door' }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${assignmentPath(s)}/notes`,
|
||||
body: () => ({ notes: 'Tickets at the side door' }),
|
||||
},
|
||||
expect: ['assignment:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'set_leg_transport_mode',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), transport_mode: 'walking' }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${assignmentPath(s)}/transport`,
|
||||
body: () => ({ transport_mode: 'walking' }),
|
||||
},
|
||||
expect: ['assignment:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'set_assignment_route_excluded',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), excluded: true }),
|
||||
rest: { method: 'put', path: (s) => `${assignmentPath(s)}/route`, body: () => ({ excluded: true }) },
|
||||
expect: ['assignment:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'move_assignment',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ ...onAssignment(s), newDayId: s.days[1].id }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${assignmentPath(s)}/move`,
|
||||
body: (s) => ({ new_day_id: s.days[1].id, order_index: 0 }),
|
||||
},
|
||||
expect: ['assignment:moved'],
|
||||
},
|
||||
{
|
||||
tool: 'set_assignment_participants',
|
||||
needs: ['assignment', 'member'],
|
||||
args: (s) => ({ ...onAssignment(s), userIds: [s.member.id] }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${assignmentPath(s)}/participants`,
|
||||
body: (s) => ({ user_ids: [s.member.id] }),
|
||||
},
|
||||
expect: ['assignment:participants'],
|
||||
},
|
||||
{
|
||||
tool: 'reorder_day_assignments',
|
||||
needs: ['assignment'],
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id, assignmentIds: [s.assignment.id] }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${dayPlanPath(s, 0)}/reorder`,
|
||||
body: (s) => ({ orderedIds: [s.assignment.id] }),
|
||||
},
|
||||
expect: ['assignment:reordered'],
|
||||
},
|
||||
{
|
||||
// Closed gap G3: the copies used to reach no open planner, on any surface.
|
||||
tool: 'copy_collection_places_to_trip',
|
||||
needs: ['collection'],
|
||||
args: (s) => ({ trip_id: s.trip.id, place_ids: [s.collectionPlace.id] }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: () => '/api/addons/collections/copy-to-trip',
|
||||
body: (s) => ({ trip_id: s.trip.id, place_ids: [s.collectionPlace.id] }),
|
||||
},
|
||||
expect: ['place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'create_day_note',
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id, text: 'Bring the museum pass' }),
|
||||
rest: { method: 'post', path: dayNotesPath, body: () => ({ text: 'Bring the museum pass' }) },
|
||||
expect: ['dayNote:created'],
|
||||
},
|
||||
{
|
||||
tool: 'update_day_note',
|
||||
needs: ['dayNote'],
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id, noteId: s.dayNote.id, text: 'Pass in the blue bag' }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${dayNotesPath(s)}/${s.dayNote.id}`,
|
||||
body: () => ({ text: 'Pass in the blue bag' }),
|
||||
},
|
||||
expect: ['dayNote:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'delete_day_note',
|
||||
needs: ['dayNote'],
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id, noteId: s.dayNote.id }),
|
||||
rest: { method: 'delete', path: (s) => `${dayNotesPath(s)}/${s.dayNote.id}` },
|
||||
expect: ['dayNote:deleted'],
|
||||
},
|
||||
{
|
||||
tool: 'update_day',
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[1].id, title: 'Montmartre' }),
|
||||
rest: { method: 'put', path: (s) => `${daysPath(s)}/${s.days[1].id}`, body: () => ({ title: 'Montmartre' }) },
|
||||
expect: ['day:updated'],
|
||||
},
|
||||
{
|
||||
// The calendar day after the last date: the list and the longer trip. Every
|
||||
// day of the scene has a date, so no undated day moves back with a road
|
||||
// trip boundary on it, and no boundary event follows.
|
||||
tool: 'create_day',
|
||||
args: (s) => ({ tripId: s.trip.id, dated: true }),
|
||||
rest: { method: 'post', path: daysPath, body: () => ({ dated: true }) },
|
||||
expect: ['day:reordered', 'trip:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'reorder_days',
|
||||
args: (s) => ({ tripId: s.trip.id, orderedIds: [s.days[1].id, s.days[0].id, s.days[2].id] }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${daysPath(s)}/reorder`,
|
||||
body: (s) => ({ orderedIds: [s.days[1].id, s.days[0].id, s.days[2].id] }),
|
||||
},
|
||||
expect: ['day:reordered'],
|
||||
},
|
||||
{
|
||||
// The check-in day of a stay: the stay and its booking go with it, and the
|
||||
// boundary on the second day moves up with its day, for every tab.
|
||||
tool: 'delete_day',
|
||||
needs: ['stay', 'place'],
|
||||
setup: boundaryOnSecondDay,
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id }),
|
||||
rest: { method: 'delete', path: (s) => `${daysPath(s)}/${s.days[0].id}` },
|
||||
expect: [
|
||||
'day:deleted',
|
||||
'day:reordered',
|
||||
{ event: 'reservation:deleted', all: true },
|
||||
'accommodation:deleted',
|
||||
{ event: 'roadtripBoundary:changed', all: true },
|
||||
'trip:updated',
|
||||
],
|
||||
},
|
||||
{
|
||||
tool: 'set_day_default_transport_mode',
|
||||
args: (s) => ({ tripId: s.trip.id, dayId: s.days[0].id, transport_mode: 'walking' }),
|
||||
rest: {
|
||||
method: 'put',
|
||||
path: (s) => `${daysPath(s)}/${s.days[0].id}/transport`,
|
||||
body: () => ({ transport_mode: 'walking' }),
|
||||
},
|
||||
expect: ['day:updated'],
|
||||
},
|
||||
],
|
||||
exempt: [
|
||||
{
|
||||
@@ -69,35 +429,4 @@ export const plan: CaseGroup = {
|
||||
args: (s) => ({ categoryId: s.category.id }),
|
||||
},
|
||||
],
|
||||
pending: [
|
||||
'assign_place_to_day',
|
||||
'set_assignment_end_day',
|
||||
'unassign_place',
|
||||
'clear_day_assignments',
|
||||
'update_assignment_time',
|
||||
'update_assignment_notes',
|
||||
'set_leg_transport_mode',
|
||||
'set_assignment_route_excluded',
|
||||
'move_assignment',
|
||||
'set_assignment_participants',
|
||||
'reorder_day_assignments',
|
||||
'copy_collection_places_to_trip',
|
||||
'create_day_note',
|
||||
'update_day_note',
|
||||
'delete_day_note',
|
||||
'update_day',
|
||||
'create_day',
|
||||
'reorder_days',
|
||||
'delete_day',
|
||||
'set_day_default_transport_mode',
|
||||
'create_and_assign_place',
|
||||
'update_place',
|
||||
'set_place_image_from_file',
|
||||
'rate_place',
|
||||
'delete_place',
|
||||
'import_places_from_url',
|
||||
'import_trip_gpx',
|
||||
'bulk_delete_places',
|
||||
'bulk_update_places',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -16,8 +16,10 @@ 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 { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { StorageService } from '../../../src/nest/storage/storage.service';
|
||||
import {
|
||||
createUser,
|
||||
createTrip,
|
||||
@@ -33,6 +35,7 @@ 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';
|
||||
import { bootTestApp } from '../../helpers/test-app';
|
||||
// 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.
|
||||
@@ -40,6 +43,7 @@ import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/db/database', async () => {
|
||||
@@ -1490,6 +1494,111 @@ describe('journey hooks on the MCP delete paths', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Journey hooks on the MCP create and update paths: the same ones REST runs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('journey hooks on the MCP create and update paths', () => {
|
||||
/** A place planned on a day of a trip with a linked journey, and the skeleton entry made from it. */
|
||||
async function plannedWithSkeleton(placeName: string, skeletonTitle = placeName) {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: placeName });
|
||||
createDayAssignment(testDb, day.id, place.id);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
await linkJourney(journey.id, trip.id);
|
||||
await seedSkeleton(journey.id, trip.id, place.id, user.id, skeletonTitle);
|
||||
return { user, trip, day, place, journey };
|
||||
}
|
||||
|
||||
it('MCP-PLACES-JRN-001: update_place carries a rename into the journey entry made from the place', async () => {
|
||||
const { user, trip, place, journey } = await plannedWithSkeleton('Musée');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, name: "Musée d'Orsay" },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
expect((await skeletonFor(journey.id, place.id))!.title).toBe("Musée d'Orsay");
|
||||
});
|
||||
|
||||
it('MCP-PLACES-JRN-002: bulk_update_places carries a new time into the journey entry of every place', async () => {
|
||||
const { user, trip, day, place, journey } = await plannedWithSkeleton('Market');
|
||||
const second = createPlace(testDb, trip.id, { name: 'Harbour' });
|
||||
createDayAssignment(testDb, day.id, second.id);
|
||||
await seedSkeleton(journey.id, trip.id, second.id, user.id, 'Harbour');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_update_places',
|
||||
arguments: { tripId: trip.id, placeIds: [place.id, second.id], place_time: '10:30' },
|
||||
});
|
||||
expect((parseToolResult(result) as { count: number }).count).toBe(2);
|
||||
});
|
||||
expect((await skeletonFor(journey.id, place.id))!.entry_time).toBe('10:30');
|
||||
expect((await skeletonFor(journey.id, second.id))!.entry_time).toBe('10:30');
|
||||
});
|
||||
|
||||
it('MCP-PLACES-JRN-003: set_place_image_from_file runs the journey hook after the new picture', async () => {
|
||||
// The entry still carries a title the place no longer has; the hook writes the place's own back.
|
||||
const { user, trip, place, journey } = await plannedWithSkeleton('Lighthouse', 'Old title');
|
||||
const storage = (await bootTestApp(realtime)).get(StorageService);
|
||||
await storage.put('files', 'beacon.jpg', Readable.from(Buffer.from('img')), { contentType: 'image/jpeg' });
|
||||
const file = await makeTripFile(orm, trip.id, {
|
||||
filename: 'beacon.jpg',
|
||||
original_name: 'beacon.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 3,
|
||||
});
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_place_image_from_file',
|
||||
arguments: { tripId: trip.id, placeId: place.id, fileId: file.id },
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
expect(await imageOf(place.id)).toMatch(/^\/uploads\/places\/[0-9a-f-]+\.jpg$/);
|
||||
expect((await skeletonFor(journey.id, place.id))!.title).toBe('Lighthouse');
|
||||
});
|
||||
|
||||
it('MCP-PLACES-JRN-004: create_place hands the new place to the journey hook, as POST /places does', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const hook = vi.spyOn(JourneyDomainService.prototype, 'onPlaceCreated');
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const created = parseToolResult(
|
||||
await h.client.callTool({ name: 'create_place', arguments: { tripId: trip.id, name: 'Night market' } }),
|
||||
) as { place: { id: number } };
|
||||
expect(hook).toHaveBeenCalledWith(trip.id, created.place.id);
|
||||
});
|
||||
} finally {
|
||||
hook.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('MCP-PLACES-JRN-005: update_place on a missing place sends nothing and runs no journey hook', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const hook = vi.spyOn(JourneyDomainService.prototype, 'onPlaceUpdated');
|
||||
broadcastMock.mockClear();
|
||||
try {
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: 99999, name: 'Nowhere' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
expect(hook).not.toHaveBeenCalled();
|
||||
expect(broadcastMock.mock.calls.filter(([, event]) => event === 'place:updated')).toHaveLength(0);
|
||||
} finally {
|
||||
hook.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A website without a scheme (#2483): the tools parse it the way REST does
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* CollectionsService.copyToTrip, the trip side (G3): after the commit every copied
|
||||
* place goes out to the trip room as `place:created`, shaped as the place routes
|
||||
* send it, and runs the places domain's journey hook, so open planners show the
|
||||
* copies without a reload. A skipped duplicate and a refused copy send nothing.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { CollectionsModule } from '../../../src/nest/collections/collections.module';
|
||||
import { CollectionsService } from '../../../src/nest/collections/collections.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeCategory, makePlace, makeTag } from '../../helpers/factories/places';
|
||||
import { findRow } from '../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let svc: CollectionsService;
|
||||
let onCreated: MockInstance<PlacesService['onCreated']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [CollectionsModule] });
|
||||
svc = t.get(CollectionsService);
|
||||
onCreated = vi.spyOn(t.get(PlacesService), 'onCreated');
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
t.realtime.reset();
|
||||
onCreated.mockClear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
onCreated.mockRestore();
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** The place events of the trip room, as [room, payload, left-out socket]. */
|
||||
const placeCreated = () =>
|
||||
t.realtime.broadcastMock.mock.calls
|
||||
.filter(([, event]) => event === 'place:created')
|
||||
.map(([room, , payload, sid]) => [room, payload, sid]);
|
||||
|
||||
describe('CollectionsService.copyToTrip: the trip hears its new places', () => {
|
||||
it('COLLECTIONS-SVC-215: each copied place goes out as place:created with its tags, then runs the journey hook; a skipped one sends nothing', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
await makeCategory(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
const tag = await makeTag(orm, user.id, { name: 'rooftop' });
|
||||
const col = await svc.createCollection(user.id, { name: 'Evenings' });
|
||||
const bar = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Sky Bar', tag_ids: [tag.id] })).place!;
|
||||
await makePlace(orm, trip.id, { name: 'Pantheon' });
|
||||
const dup = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Pantheon' })).place!;
|
||||
t.realtime.broadcastMock.mockClear();
|
||||
|
||||
const res = await svc.copyToTrip(
|
||||
user.id,
|
||||
{ trip_id: trip.id, place_ids: [bar.id, dup.id] },
|
||||
svc.tripEvents(trip.id, 'sock-copy'),
|
||||
);
|
||||
|
||||
expect(res).toEqual({ copied: 1, skipped: [{ id: dup.id, name: 'Pantheon' }] });
|
||||
const copy = (await findRow(orm, Places, { trip: trip.id, name: 'Sky Bar' }))!;
|
||||
const sent = placeCreated();
|
||||
expect(sent).toHaveLength(1);
|
||||
// The trip room, the REST socket left out, the place as the place routes send it.
|
||||
const [room, payload, sid] = sent[0];
|
||||
expect([room, sid]).toEqual([trip.id, 'sock-copy']);
|
||||
expect((payload as { place: { id: number; tags: { id: number }[] } }).place).toMatchObject({
|
||||
id: copy.id,
|
||||
tags: [expect.objectContaining({ id: tag.id })],
|
||||
});
|
||||
expect(onCreated.mock.calls).toEqual([[String(trip.id), copy.id]]);
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-216: a refused copy sends nothing and runs no hook', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
await makeCategory(orm);
|
||||
const col = await svc.createCollection(owner.id, { name: 'Mine' });
|
||||
const gem = (await svc.savePlace(owner.id, { collection_id: col.id, name: 'Hidden gem' })).place!;
|
||||
const theirs = await makeTrip(orm, stranger.id);
|
||||
t.realtime.broadcastMock.mockClear();
|
||||
|
||||
await expect(
|
||||
svc.copyToTrip(owner.id, { trip_id: theirs.id, place_ids: [gem.id] }, svc.tripEvents(theirs.id, undefined)),
|
||||
).rejects.toThrow();
|
||||
expect(placeCreated()).toEqual([]);
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('COLLECTIONS-SVC-225: a plugin copy has no socket, so no tab of the trip is left out', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
await makeCategory(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
const col = await svc.createCollection(user.id, { name: 'Plugin' });
|
||||
const spot = (await svc.savePlace(user.id, { collection_id: col.id, name: 'Viewpoint' })).place!;
|
||||
t.realtime.broadcastMock.mockClear();
|
||||
|
||||
await svc.copyToTrip(user.id, { trip_id: trip.id, place_ids: [spot.id] }, svc.tripEvents(trip.id, undefined));
|
||||
|
||||
expect(placeCreated().map(([room, , sid]) => [room, sid])).toEqual([[trip.id, undefined]]);
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,7 @@ function build(
|
||||
updateCollection: vi.fn(() => ({})),
|
||||
savePlace: vi.fn(() => ({})),
|
||||
copyToTrip: vi.fn(() => ({})),
|
||||
tripEvents: vi.fn(() => ({ emit: vi.fn(), emitAll: vi.fn() })),
|
||||
deletePlace: vi.fn(() => Promise.resolve(undefined)),
|
||||
...overrides.collections,
|
||||
} as never,
|
||||
@@ -451,6 +452,16 @@ describe('addon-gated domains validate their input', () => {
|
||||
expect(updateCollection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ADDONERR-010b2 a copy hands the service the target trip and no socket, so every open planner hears it', async () => {
|
||||
const sink = { emit: vi.fn(), emitAll: vi.fn() };
|
||||
const tripEvents = vi.fn(() => sink);
|
||||
const copyToTrip = vi.fn(() => ({ copied: 1, skipped: [] }));
|
||||
const host = build({ collections: { tripEvents, copyToTrip } });
|
||||
await host.dispatch(req('collections.copyToTrip', { input: { trip_id: 7, place_ids: [3] } }), 42);
|
||||
expect(tripEvents).toHaveBeenCalledWith(7, undefined);
|
||||
expect(copyToTrip).toHaveBeenCalledWith(42, { trip_id: 7, place_ids: [3] }, sink);
|
||||
});
|
||||
|
||||
it('ADDONERR-010c a journal entry update carries a non-object input through asPayload', async () => {
|
||||
const updateEntry = vi.fn(() => ({ id: 1 }));
|
||||
const host = build({ journey: { updateEntry } });
|
||||
|
||||
Reference in New Issue
Block a user