mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(mcp): collection membership, bulk visited marking, and upcoming bookings
Asking whether a place is already in one of your lists took a walk over every collection and lost the coordinate tolerance the server matches on. Marking a trip's places visited across every list holding them was one call per place. The dashboard's next-booking view is a three way union that folds in synthetic check-in and check-out rows, so rebuilding it from per-trip summaries silently drops the accommodations.
This commit is contained in:
@@ -14,13 +14,14 @@ import {
|
||||
collectionSavePlaceRequestSchema, collectionPlaceUpdateRequestSchema,
|
||||
collectionCopyToTripRequestSchema, collectionLabelCreateRequestSchema,
|
||||
collectionLabelUpdateRequestSchema, collectionLabelAssignRequestSchema,
|
||||
collectionInviteRequestSchema, COLLECTION_STATUSES, COLLECTION_ROLES,
|
||||
collectionInviteRequestSchema, collectionSetStatusFromTripRequestSchema,
|
||||
COLLECTION_STATUSES, COLLECTION_ROLES,
|
||||
} from '@trek/shared';
|
||||
import type {
|
||||
CollectionCreateRequest, CollectionUpdateRequest, CollectionSavePlaceRequest,
|
||||
CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest,
|
||||
CollectionLabelCreateRequest, CollectionLabelUpdateRequest, CollectionLabelAssignRequest,
|
||||
CollectionStatus, CollectionRole,
|
||||
CollectionSetStatusFromTripRequest, CollectionStatus, CollectionRole,
|
||||
} from '@trek/shared';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
@@ -111,6 +112,27 @@ export class CollectionsMcp {
|
||||
} catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'find_place_in_collections',
|
||||
description: 'Answer "is this place already on one of my lists?" across the whole library in one call, and name the lists it is on with its status in each. Prefer this over walking get_collection list by list: it applies the same provider-id and coordinate-proximity match the app\'s own saved indicator uses, which a name comparison over the returned places cannot reproduce. Identify the place by google_place_id / google_ftid (from search_place) or by lat+lng; without one of those there is no signal strong enough to claim it is the same place and nothing is reported as saved.',
|
||||
inputSchema: {
|
||||
google_place_id: z.string().max(200).optional().describe('Google Places id of the place to look up'),
|
||||
google_ftid: z.string().max(200).optional().describe('Google feature id (ftid) of the place to look up'),
|
||||
name: z.string().max(200).optional().describe('Place name. Carried for parity with the REST lookup and never matched on alone, since every repeated name (any "Starbucks") would answer to it.'),
|
||||
lat: z.number().min(-90).max(90).optional().describe('Latitude; pass together with lng to match by location'),
|
||||
lng: z.number().min(-180).max(180).optional().describe('Longitude; pass together with lat to match by location'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: collectionsAddonOn,
|
||||
access: { group: 'collections', mode: 'read' },
|
||||
})
|
||||
async findPlaceInCollections(
|
||||
query: { google_place_id?: string; google_ftid?: string; name?: string; lat?: number; lng?: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
try { return ok(this.collections.findMembership(ctx.userId, query)); } catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
// ── Collections CRUD ─────────────────────────────────────────────────
|
||||
|
||||
@Tool({
|
||||
@@ -227,6 +249,22 @@ export class CollectionsMcp {
|
||||
try { return ok({ place: this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
description: 'Set a status on every saved copy of the given TRIP places, in every list they are on. Use this after a day out to mark places visited: set_collection_place_status takes one collection_places id, so the same trip place saved to three lists would need three calls and a lookup to find them. Ids here are trip place ids. Lists the user may only read are skipped rather than refused. Returns how many saved places changed and how many of the trip places were found in at least one list.',
|
||||
inputSchema: collectionSetStatusFromTripRequestSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: collectionsAddonOn,
|
||||
access: { group: 'collections', mode: 'write' },
|
||||
})
|
||||
async setCollectionPlaceStatusFromTrip(
|
||||
{ trip_id, place_ids, status }: CollectionSetStatusFromTripRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const demo = this.denyDemo(ctx.userId); if (demo) return demo;
|
||||
try { return ok(this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); }
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'rate_collection_place',
|
||||
description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
McpController, Tool, ResourceTemplate, type McpContext,
|
||||
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, errorResult, ok,
|
||||
} from '../../nest-mcp';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
@@ -26,12 +27,12 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr
|
||||
//
|
||||
// `transit` is deliberately absent: the picker does not offer it either. A transit
|
||||
// booking carries a provider itinerary in metadata.transit, and create_transit_journey
|
||||
// is what writes one — a hand-made `transit` row would be a shape the transit UI
|
||||
// is what writes one. A hand-made `transit` row would be a shape the transit UI
|
||||
// does not expect.
|
||||
const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const;
|
||||
/** Only these two carry per-segment detail: the transport form writes metadata.legs for a flight or a train and for nothing else. */
|
||||
const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const;
|
||||
/** Everything the picker offers that is not a transport — create_reservation's half. */
|
||||
/** Everything the picker offers that is not a transport: create_reservation's half. */
|
||||
const BOOKING_TYPES = ['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'] as const;
|
||||
|
||||
/**
|
||||
@@ -41,7 +42,7 @@ const BOOKING_TYPES = ['hotel', 'restaurant', 'event', 'tour', 'activity', 'park
|
||||
* as an href.
|
||||
*/
|
||||
const urlField = reservationUrlSchema.max(2000).optional()
|
||||
.describe('Link to the booking — the airline/hotel confirmation page, the ticket. Shown as a link on the booking.');
|
||||
.describe('Link to the booking: the airline/hotel confirmation page, the ticket. Shown as a link on the booking.');
|
||||
|
||||
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
|
||||
type BookingType = typeof BOOKING_TYPES[number];
|
||||
@@ -327,13 +328,13 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_reservation',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending, so the user must confirm it. For anything travelled ON (flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other) use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(BOOKING_TYPES).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
reservation_end_time: z.string().optional().describe('When it ends — a dinner from 19:00 to 21:30, a tour that runs all afternoon. Ignored for hotels, whose span is the check-in/check-out days.'),
|
||||
reservation_end_time: z.string().optional().describe('When it ends: a dinner from 19:00 to 21:30, a tour that runs all afternoon. Ignored for hotels, whose span is the check-in/check-out days.'),
|
||||
url: urlField,
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
@@ -408,7 +409,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_reservation',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON (flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other) use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
@@ -481,7 +482,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'set_reservation_travelers',
|
||||
description: 'Set who is travelling on a booking, replacing the current list. Pass the user IDs of trip members or guests from list_trip_members; an empty array clears the list. Somebody who is not on the trip is ignored rather than added — use add_trip_member for a person with a TREK account, or create_trip_guest for one without.',
|
||||
description: 'Set who is travelling on a booking, replacing the current list. Pass the user IDs of trip members or guests from list_trip_members; an empty array clears the list. Somebody who is not on the trip is ignored rather than added: use add_trip_member for a person with a TREK account, or create_trip_guest for one without.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
@@ -596,6 +597,29 @@ export class ReservationsMcp {
|
||||
return ok({ reservation, accommodation_id: reservation?.accommodation_id ?? null });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cross-trip reads
|
||||
//
|
||||
// Every other entry here is trip-scoped and checks verifyTripAccess against
|
||||
// the tripId it was handed. This one has no tripId: the visible_trips CTE in
|
||||
// listUpcoming is the access check, exactly as for GET /api/reservations/upcoming,
|
||||
// so a booking on a trip the caller neither owns nor is a member of is never
|
||||
// in the result set to begin with.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool({
|
||||
name: 'list_upcoming_reservations',
|
||||
description: 'The next bookings across ALL of the user\'s trips, soonest first: "what is coming up?", "when is my next flight?". Prefer this over get_trip_summary when no particular trip is in question, or when the trip is unknown. Hotel stays appear as their check-in and check-out moments (the stay itself covers a range and is not a point in time), which no per-trip reservation list reports. Cancelled bookings and archived trips are left out.',
|
||||
inputSchema: {
|
||||
limit: z.number().int().min(1).max(50).optional().describe('How many entries to return, soonest first (default 6, the dashboard widget\'s size)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'reservations', mode: 'read' },
|
||||
})
|
||||
async listUpcomingReservations({ limit }: { limit?: number }, ctx: McpContext) {
|
||||
return ok({ reservations: this.reservations.listUpcoming(ctx.userId, limit) });
|
||||
}
|
||||
|
||||
@ResourceTemplate({
|
||||
name: 'trip-reservations',
|
||||
uriTemplate: 'trek://trips/{tripId}/reservations',
|
||||
@@ -637,7 +661,7 @@ export class ReservationsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_transport',
|
||||
description: 'Create a transport booking for a trip — flight, train, bus, car, taxi, bicycle, cruise, ferry or transport_other. For scheduled public transit use create_transit_journey, which attaches the provider itinerary. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
description: 'Create a transport booking for a trip: flight, train, bus, car, taxi, bicycle, cruise, ferry or transport_other. For scheduled public transit use create_transit_journey, which attaches the provider itinerary. Use endpoints[] to record origin/destination and intermediate stops; for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending, so confirm it with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(CREATABLE_TRANSPORT_TYPES),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the collections MCP surface (CollectionsMcp, DI-discovered):
|
||||
* the 25 tools ported 1:1 from the legacy src/mcp/tools/collections.ts
|
||||
* registrar. New with the DI fold — the legacy registrar had no tool-level
|
||||
* registrar, plus the two later additions that closed the gap to REST
|
||||
* (find_place_in_collections, set_collection_place_status_from_trip).
|
||||
* New with the DI fold: the legacy registrar had no tool-level
|
||||
* suite — so this is a characterization of the ported behavior: payload
|
||||
* shapes, error texts (the service's thrown httpError messages surfaced via
|
||||
* fail()), the owner-only available-users gate, demo denial on writes, the
|
||||
@@ -103,8 +105,17 @@ function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'a
|
||||
|
||||
function seedPlace(colId: number, ownerId: number, name: string, extra: Record<string, unknown> = {}): number {
|
||||
return Number(testDb.prepare(
|
||||
'INSERT INTO collection_places (collection_id, owner_id, saved_by, name, lat, lng, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(colId, ownerId, ownerId, name, extra.lat ?? null, extra.lng ?? null, extra.status ?? 'idea').lastInsertRowid);
|
||||
`INSERT INTO collection_places (collection_id, owner_id, saved_by, name, lat, lng, status, google_place_id, source_trip_id, source_place_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
colId, ownerId, ownerId, name,
|
||||
extra.lat ?? null, extra.lng ?? null, extra.status ?? 'idea',
|
||||
extra.google_place_id ?? null, extra.source_trip_id ?? null, extra.source_place_id ?? null,
|
||||
).lastInsertRowid);
|
||||
}
|
||||
|
||||
function statusOf(placeId: number): string | undefined {
|
||||
return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string } | undefined)?.status;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -183,6 +194,79 @@ describe('Tool: available_collection_users', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library-wide membership rollup
|
||||
//
|
||||
// The inspector's "already saved" lookup (GET /addons/collections/membership)
|
||||
// had no tool behind it, and walking get_collection cannot stand in for it: the
|
||||
// match runs on provider ids and a coordinate tolerance, not on the names a
|
||||
// caller could compare itself. The cases below pin the signals, not just the
|
||||
// payload shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: find_place_in_collections', () => {
|
||||
it('names every visible list holding the place, with its per-list status and edit right', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const mine = seedCollection(user.id, 'Paris');
|
||||
const shared = seedCollection(other.id, 'Their museums');
|
||||
const hidden = seedCollection(other.id, 'Not shared');
|
||||
addMember(shared, user.id, 'viewer');
|
||||
seedPlace(mine, user.id, 'Louvre', { google_place_id: 'g-louvre', status: 'want' });
|
||||
seedPlace(shared, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
seedPlace(hidden, other.id, 'Louvre', { google_place_id: 'g-louvre' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'find_place_in_collections',
|
||||
arguments: { google_place_id: 'g-louvre' },
|
||||
})) as { saved: boolean; lists: { collection_id: number; name: string; status: string; can_edit: boolean }[] };
|
||||
|
||||
expect(data.saved).toBe(true);
|
||||
const byId = new Map(data.lists.map((l) => [l.collection_id, l]));
|
||||
expect([...byId.keys()].sort((a, b) => a - b)).toEqual([mine, shared].sort((a, b) => a - b));
|
||||
expect(byId.get(mine)).toMatchObject({ name: 'Paris', status: 'want', can_edit: true });
|
||||
expect(byId.get(shared)).toMatchObject({ name: 'Their museums', status: 'idea', can_edit: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('matches by coordinates within the dedup tolerance and not outside it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Rome');
|
||||
seedPlace(col, user.id, 'Pantheon', { lat: 41.8986, lng: 12.4769 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const near = parseToolResult(await h.client.callTool({
|
||||
name: 'find_place_in_collections',
|
||||
arguments: { lat: 41.89865, lng: 12.4769 },
|
||||
})) as { saved: boolean; lists: unknown[] };
|
||||
expect(near.saved).toBe(true);
|
||||
expect(near.lists).toHaveLength(1);
|
||||
|
||||
const far = parseToolResult(await h.client.callTool({
|
||||
name: 'find_place_in_collections',
|
||||
arguments: { lat: 41.899, lng: 12.4769 },
|
||||
})) as { saved: boolean; lists: unknown[] };
|
||||
expect(far.saved).toBe(false);
|
||||
expect(far.lists).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports nothing for a name-only query, since a repeated name is not a match signal', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const col = seedCollection(user.id, 'Coffee');
|
||||
seedPlace(col, user.id, 'Starbucks', { lat: 47.6062, lng: -122.3321 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'find_place_in_collections',
|
||||
arguments: { name: 'Starbucks' },
|
||||
})) as { saved: boolean; lists: unknown[] };
|
||||
expect(data).toEqual({ saved: false, lists: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collections CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -298,6 +382,114 @@ describe('Tool: update_collection_place / set_collection_place_status / rate_col
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk status from the trip side
|
||||
//
|
||||
// set_collection_place_status takes one collection_places id, so "we went there
|
||||
// today" had to be repeated per list after finding the copies. This one names
|
||||
// TRIP place ids and resolves the copies the way the membership lookup does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_collection_place_status_from_trip', () => {
|
||||
it('marks every saved copy of a trip place across the lists holding it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const louvre = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 });
|
||||
const orsay = createPlace(testDb, trip.id, { name: "Musee d'Orsay", lat: 48.86, lng: 2.3266 });
|
||||
const paris = seedCollection(user.id, 'Paris');
|
||||
const museums = seedCollection(user.id, 'Museums');
|
||||
// Two different match signals: coordinates in one list, the source link the
|
||||
// saved-from-trip path writes in the other.
|
||||
const byCoords = seedPlace(paris, user.id, 'Louvre', { lat: 48.8606, lng: 2.3376 });
|
||||
const bySource = seedPlace(museums, user.id, 'Louvre', { source_trip_id: trip.id, source_place_id: louvre.id });
|
||||
const untouched = seedPlace(paris, user.id, 'Eiffel Tower', { lat: 48.8584, lng: 2.2945 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [louvre.id, orsay.id], status: 'visited' },
|
||||
}));
|
||||
// Orsay is saved nowhere, so it counts towards neither number.
|
||||
expect(data).toEqual({ updated: 2, places: 1 });
|
||||
});
|
||||
|
||||
expect(statusOf(byCoords)).toBe('visited');
|
||||
expect(statusOf(bySource)).toBe('visited');
|
||||
expect(statusOf(untouched)).toBe('idea');
|
||||
});
|
||||
|
||||
it('skips a list the user may only read instead of refusing the batch', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const prado = createPlace(testDb, trip.id, { name: 'Prado', lat: 40.4138, lng: -3.6921 });
|
||||
const own = seedCollection(user.id, 'Madrid');
|
||||
const readOnly = seedCollection(owner.id, 'Their list');
|
||||
addMember(readOnly, user.id, 'viewer');
|
||||
const mine = seedPlace(own, user.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
const theirs = seedPlace(readOnly, owner.id, 'Prado', { lat: 40.4138, lng: -3.6921 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [prado.id], status: 'visited' },
|
||||
}));
|
||||
expect(data).toEqual({ updated: 1, places: 1 });
|
||||
});
|
||||
|
||||
expect(statusOf(mine)).toBe('visited');
|
||||
expect(statusOf(theirs)).toBe('idea');
|
||||
});
|
||||
|
||||
it('surfaces the 404 text for a trip the user cannot access, leaving statuses alone', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, stranger.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Alhambra', lat: 37.176, lng: -3.5881 });
|
||||
const col = seedCollection(user.id, 'Spain');
|
||||
const saved = seedPlace(col, user.id, 'Alhambra', { lat: 37.176, lng: -3.5881 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [place.id], status: 'visited' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(errorText(result)).toBe('Trip not found');
|
||||
});
|
||||
expect(statusOf(saved)).toBe('idea');
|
||||
});
|
||||
|
||||
it('refuses an empty place_ids, and falls back to idea on an unknown status like the REST contract', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia', lat: 41.4036, lng: 2.1744 });
|
||||
const col = seedCollection(user.id, 'Barcelona');
|
||||
const saved = seedPlace(col, user.id, 'Sagrada Familia', { lat: 41.4036, lng: 2.1744, status: 'want' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const empty = await h.client.callTool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [], status: 'visited' },
|
||||
});
|
||||
expect(empty.isError).toBe(true);
|
||||
expect(statusOf(saved)).toBe('want');
|
||||
|
||||
// collectionStatusSchema carries .catch('idea'), so REST coerces rather
|
||||
// than rejects; the tool shares the contract and behaves the same.
|
||||
await h.client.callTool({
|
||||
name: 'set_collection_place_status_from_trip',
|
||||
arguments: { trip_id: trip.id, place_ids: [place.id], status: 'teleported' },
|
||||
});
|
||||
expect(statusOf(saved)).toBe('idea');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: delete_collection_place', () => {
|
||||
it('deletes a place; an unknown id surfaces the 404 text', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
@@ -466,11 +658,12 @@ describe('Sharing tools', () => {
|
||||
// Scope gating (collections read/write, registration-time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const READ_TOOLS = ['list_collections', 'get_collection', 'available_collection_users'];
|
||||
const READ_TOOLS = ['list_collections', 'get_collection', 'available_collection_users', 'find_place_in_collections'];
|
||||
const WRITE_TOOLS = [
|
||||
'create_collection', 'update_collection', 'delete_collection', 'reorder_collections',
|
||||
'save_place_to_collection', 'save_trip_places_to_collection', 'update_collection_place',
|
||||
'set_collection_place_status', 'rate_collection_place', 'delete_collection_place',
|
||||
'set_collection_place_status', 'set_collection_place_status_from_trip',
|
||||
'rate_collection_place', 'delete_collection_place',
|
||||
'copy_collection_places_to_trip', 'create_collection_label', 'update_collection_label',
|
||||
'delete_collection_label', 'assign_collection_labels', 'invite_to_collection',
|
||||
'set_collection_member_role', 'remove_collection_member', 'cancel_collection_invite',
|
||||
@@ -487,7 +680,7 @@ describe('Collection tools — scope gating', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('registers all 25 tools with null scopes (full access)', async () => {
|
||||
it('registers all 27 tools with null scopes (full access)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, null);
|
||||
for (const tool of [...READ_TOOLS, ...WRITE_TOOLS]) expect(names).toContain(tool);
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, addTripMember } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -489,8 +489,8 @@ describe('Resource: trek://trips/{tripId}/reservations', () => {
|
||||
// Reads already hydrate a booking's travellers; there was no way to write them,
|
||||
// so an imported booking could not record who was on it. The roster filter lives
|
||||
// in the service, so the cases below pin that an off-trip id is dropped rather
|
||||
// than attached, and that a guest — the only representation of a companion
|
||||
// without an account — is assignable like any member.
|
||||
// than attached, and that a guest, the only representation of a companion
|
||||
// without an account, is assignable like any member.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_reservation_travelers', () => {
|
||||
@@ -788,3 +788,108 @@ describe('Reservation tools: url and reservation_end_time', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_upcoming_reservations
|
||||
//
|
||||
// The one cross-trip read here. GET /api/reservations/upcoming scopes itself in
|
||||
// SQL (owned or joined, not archived) rather than through verifyTripAccess, so
|
||||
// the visibility cases below are the access test for this tool. The hotel arm is
|
||||
// the part no per-trip list can stand in for: a stay contributes a check-in and
|
||||
// a check-out moment that exist in day_accommodations, not in reservations.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_upcoming_reservations', () => {
|
||||
function dateInDays(days: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function seedBooking(
|
||||
tripId: number,
|
||||
title: string,
|
||||
time: string | null,
|
||||
extra: Partial<{ type: string; status: string }> = {},
|
||||
): number {
|
||||
return Number(testDb.prepare(
|
||||
'INSERT INTO reservations (trip_id, title, type, status, reservation_time) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(tripId, title, extra.type ?? 'restaurant', extra.status ?? 'pending', time).lastInsertRowid);
|
||||
}
|
||||
|
||||
async function upcoming(userId: number, args: Record<string, unknown> = {}) {
|
||||
let out: { title: string; type: string; trip_id: number }[] = [];
|
||||
await withHarness(userId, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'list_upcoming_reservations', arguments: args })) as {
|
||||
reservations: { title: string; type: string; trip_id: number }[];
|
||||
};
|
||||
out = data.reservations;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
it('spans every trip the user can see, soonest first, and stops at the trips they cannot', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const owned = createTrip(testDb, user.id);
|
||||
const joined = createTrip(testDb, other.id);
|
||||
const stranger = createTrip(testDb, other.id);
|
||||
addTripMember(testDb, joined.id, user.id);
|
||||
|
||||
seedBooking(owned.id, 'Flight to Paris', `${dateInDays(4)}T08:00:00`, { type: 'flight' });
|
||||
seedBooking(joined.id, 'Group dinner', `${dateInDays(2)}T19:00:00`);
|
||||
seedBooking(stranger.id, 'Not mine', `${dateInDays(1)}T07:00:00`);
|
||||
|
||||
const rows = await upcoming(user.id);
|
||||
expect(rows.map((r) => r.title)).toEqual(['Group dinner', 'Flight to Paris']);
|
||||
});
|
||||
|
||||
it('reports a hotel stay as its check-in and check-out moments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createCategory(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const arrival = createDay(testDb, trip.id, { date: dateInDays(3) });
|
||||
const departure = createDay(testDb, trip.id, { date: dateInDays(6) });
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Hotel Astoria' });
|
||||
createDayAccommodation(testDb, trip.id, hotel.id, arrival.id, departure.id, { check_in: '15:00', check_out: '11:00' });
|
||||
|
||||
const rows = await upcoming(user.id);
|
||||
expect(rows.map((r) => [r.type, r.title])).toEqual([
|
||||
['checkin', 'Hotel Astoria'],
|
||||
['checkout', 'Hotel Astoria'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves out cancelled bookings and archived trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const archived = createTrip(testDb, user.id);
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||||
|
||||
seedBooking(trip.id, 'Still on', `${dateInDays(2)}T12:00:00`);
|
||||
seedBooking(trip.id, 'Called off', `${dateInDays(1)}T12:00:00`, { status: 'cancelled' });
|
||||
seedBooking(archived.id, 'Last year', `${dateInDays(3)}T12:00:00`);
|
||||
|
||||
const rows = await upcoming(user.id);
|
||||
expect(rows.map((r) => r.title)).toEqual(['Still on']);
|
||||
});
|
||||
|
||||
it('returns the dashboard six by default and honours a limit', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
for (let i = 1; i <= 8; i++) seedBooking(trip.id, `Booking ${i}`, `${dateInDays(i)}T09:00:00`);
|
||||
|
||||
expect((await upcoming(user.id)).map((r) => r.title)).toEqual([
|
||||
'Booking 1', 'Booking 2', 'Booking 3', 'Booking 4', 'Booking 5', 'Booking 6',
|
||||
]);
|
||||
expect((await upcoming(user.id, { limit: 2 })).map((r) => r.title)).toEqual(['Booking 1', 'Booking 2']);
|
||||
});
|
||||
|
||||
it('refuses a limit outside the allowed range', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_upcoming_reservations', arguments: { limit: 0 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user