From f85a87e6d066efad4abe5e1c3a1c496906d3047b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 13:07:37 +0200 Subject: [PATCH] feat(mcp): reorder days and insert one mid-trip An imported itinerary landed in whatever order the assistant appended it, with no way to fix it afterwards: create_day only ever appended, and days.service.insert was reachable from exactly one place in the server, the REST controller. Because both halves were missing, inserting a day into the middle of a trip could not be expressed at all. --- server/src/nest/days/days.mcp.ts | 92 +++++- server/tests/unit/mcp/tools-days.test.ts | 399 ++++++++++++++++++++++- 2 files changed, 471 insertions(+), 20 deletions(-) diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index dcfd60007..25bc75baf 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -1,13 +1,17 @@ import { McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, - demoDenied, ok, + demoDenied, errorResult, ok, } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; -import { DaysService } from './days.service'; +import { + dayCreateRequestSchema, dayReorderRequestSchema, dayUpdateRequestSchema, +} from '@trek/shared'; +import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared'; +import { DaysService, DayReorderError } from './days.service'; function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -25,6 +29,11 @@ function parseId(value: string | string[]): number | null { * trips:delete / trips:share-only tokens; the declarative read marker is * marginally narrower for those, same trade day-notes made). No addon gate — * days are core. + * + * reorder_days, create_day's `position` and update_day's `notes` sit outside + * that port: the REST controller has carried all three since #589, and until + * they landed here a model could append and rename days but not move one, slot + * one in mid-trip, or write a day's notes. */ @McpController() export class DaysMcp { @@ -36,50 +45,101 @@ export class DaysMcp { @Tool({ name: 'update_day', - description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").', + description: 'Set the title and/or the notes of a day in a trip (e.g. "Arrival in Paris", "Free day"). An omitted field keeps its current value, so the title and the notes can be changed independently.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), - title: z.string().max(200).nullable().describe('Day title, or null to clear it'), + // The shared contract leaves title unbounded, as the raw-body route it + // replaced did; the tool keeps the cap it was ported with so a model + // cannot park a paragraph in a day header. + title: z.string().max(200).nullable().optional().describe('Day title, or null to clear it'), + notes: dayUpdateRequestSchema.shape.notes.describe('Free-text notes for the day; an empty string clears them'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, }) async updateDay( - { tripId, dayId, title }: { tripId: number; dayId: number; title: string | null }, + { tripId, dayId, ...fields }: { tripId: number; dayId: number } & DayUpdateRequest, ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); const current = this.days.getDay(dayId, tripId); - if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - const updated = this.days.update(dayId, current, { title }); + if (!current) return errorResult('Day not found.'); + // The rest spread carries only the keys the caller actually sent, which is + // what update()'s presence sentinels need: naming the two fields here would + // hand it an undefined notes on a title-only call and wipe the day's notes. + const updated = this.days.update(dayId, current, fields); this.guards.safeBroadcast(tripId, 'day:updated', { day: updated }); return ok({ day: updated }); } @Tool({ name: 'create_day', - description: 'Add a new day to a trip (optionally with a specific date and notes).', + description: 'Add a day to a trip. Without `position` the day is appended at the end, optionally with a date and notes. With `position` an empty day is slotted in at that place instead, which is the way to add a day in the middle of an itinerary that already has days.', inputSchema: { tripId: z.number().int().positive(), - date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'), - notes: z.string().optional(), + date: dayCreateRequestSchema.shape.date.describe('ISO date string YYYY-MM-DD, optional for dateless trips'), + notes: dayCreateRequestSchema.shape.notes, + position: dayCreateRequestSchema.shape.position.describe('1-based slot to insert an empty day at; omit to append at the end. On a dated trip the days keep their calendar slots, so the trip gains one day at its end and bookings move with the day they sit on. date and notes are ignored when this is set, as on the REST route.'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, }) async createDay( - { tripId, date, notes }: { tripId: number; date?: string; notes?: string }, + { tripId, date, notes, position }: { tripId: number } & DayCreateRequest, ctx: McpContext, ) { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); - const day = this.days.create(tripId, date, notes); - this.guards.safeBroadcast(tripId, 'day:created', { day }); - return ok({ day }); + if (position === undefined) { + const day = this.days.create(tripId, date, notes); + this.guards.safeBroadcast(tripId, 'day:created', { day }); + return ok({ day }); + } + try { + const day = this.days.insert(tripId, position); + // An insert renumbers and re-dates every later day, so collaborators get + // the list-wide event and refetch, the same one the REST create route sends. + this.guards.safeBroadcast(tripId, 'day:reordered', { day }); + return ok({ day }); + } catch (err) { + // REST lets this bubble into a 500; a tool caller can act on the sentence. + if (err instanceof DayReorderError) return errorResult(err.message); + throw err; + } + } + + @Tool({ + name: 'reorder_days', + description: 'Reorder the days of a trip by listing every one of its day IDs in the desired order. This moves whole days of the itinerary; to move places around inside a single day use reorder_day_assignments instead. Each day keeps its places, notes, stays and bookings, and on a dated trip the calendar dates stay pinned to their slots, so the content moves across the dates.', + inputSchema: { + tripId: z.number().int().positive(), + orderedIds: dayReorderRequestSchema.shape.orderedIds.min(1).describe('Every day ID of the trip, in the desired order'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + }) + async reorderDays( + { tripId, orderedIds }: { tripId: number } & DayReorderRequest, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + try { + this.days.reorder(tripId, orderedIds); + } catch (err) { + // A non-permutation and an inverted stay are both the caller's input, so + // they come back as tool errors rather than a throw the SDK has to dress up. + if (err instanceof DayReorderError) return errorResult(err.message); + throw err; + } + // REST parity shape ({ orderedIds }): the client refetches the day list off it. + this.guards.safeBroadcast(tripId, 'day:reordered', { orderedIds }); + return ok({ success: true }); } @Tool({ @@ -96,7 +156,7 @@ export class DaysMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); - if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); this.days.remove(dayId); // REST parity shape ({ dayId }) — the client reads payload.dayId, so the { id } // variant never removed the day from collaborator screens. @@ -122,7 +182,7 @@ export class DaysMcp { if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); - if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + if (!this.days.getDay(dayId, tripId)) return errorResult('Day not found.'); const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null); this.guards.safeBroadcast(tripId, 'day:updated', { day }); return ok({ day }); diff --git a/server/tests/unit/mcp/tools-days.test.ts b/server/tests/unit/mcp/tools-days.test.ts index ad3ac0444..d3057383e 100644 --- a/server/tests/unit/mcp/tools-days.test.ts +++ b/server/tests/unit/mcp/tools-days.test.ts @@ -1,7 +1,8 @@ /** - * Unit tests for the DI-discovered DaysMcp: the update_day tool and the - * trek://trips/{tripId}/days resource (moved from resources.test.ts when the - * legacy registrar was ported). + * Unit tests for the DI-discovered DaysMcp: the update_day and reorder_days + * tools, create_day's mid-trip insert, and the trek://trips/{tripId}/days + * resource (moved from resources.test.ts when the legacy registrar was ported). + * create_day's plain append is covered in tools-days-accommodations.test.ts. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -37,7 +38,9 @@ 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, createDayAssignment } from '../../helpers/factories'; +import { + createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation, +} from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -60,6 +63,23 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) try { await fn(h); } finally { await h.cleanup(); } } +/** The stored day row, which is what a tool's echo can disagree with. */ +function dayRow(dayId: number) { + return testDb.prepare('SELECT title, notes, day_number, date FROM days WHERE id = ?').get(dayId) as + { title: string | null; notes: string | null; day_number: number; date: string | null }; +} + +/** Day ids of a trip in stored order, so a reorder can be read back positionally. */ +function dayIdsInOrder(tripId: number): number[] { + return (testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[]) + .map(r => r.id); +} + +function dayDatesInOrder(tripId: number): (string | null)[] { + return (testDb.prepare('SELECT date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { date: string | null }[]) + .map(r => r.date); +} + // --------------------------------------------------------------------------- // update_day // --------------------------------------------------------------------------- @@ -111,6 +131,71 @@ describe('Tool: update_day', () => { }); }); + it('writes notes without being sent a title, and keeps the title', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { title: 'Arrival in Paris' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, notes: 'Ferry leaves at 08:00' }, + }); + const data = parseToolResult(result) as { day: { notes: string } }; + expect(data.day.notes).toBe('Ferry leaves at 08:00'); + }); + + expect(dayRow(day.id)).toMatchObject({ title: 'Arrival in Paris', notes: 'Ferry leaves at 08:00' }); + }); + + it('sets a title and notes in one call', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, title: 'Free day', notes: 'Nothing booked' }, + }); + }); + + expect(dayRow(day.id)).toMatchObject({ title: 'Free day', notes: 'Nothing booked' }); + }); + + it('clears the notes with an empty string', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { title: 'Arrival' }); + testDb.prepare('UPDATE days SET notes = ? WHERE id = ?').run('Ferry leaves at 08:00', day.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, notes: '' }, + }); + }); + + expect(dayRow(day.id)).toMatchObject({ title: 'Arrival', notes: null }); + }); + + it('refuses a non-string notes value', 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 = await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, notes: 42 }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('Invalid arguments'); + }); + + expect(dayRow(day.id).notes).toBeNull(); + }); + it('broadcasts day:updated event', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -159,6 +244,312 @@ describe('Tool: update_day', () => { }); }); +// --------------------------------------------------------------------------- +// create_day with a position (the mid-trip insert behind POST /days) +// --------------------------------------------------------------------------- + +describe('Tool: create_day (position)', () => { + it('inserts an empty day into the middle of a dateless trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + const d3 = createDay(testDb, trip.id); + + let insertedId = 0; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id, position: 2 }, + }); + insertedId = (parseToolResult(result) as { day: { id: number } }).day.id; + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, insertedId, d2.id, d3.id]); + expect(dayRow(insertedId)).toMatchObject({ day_number: 2, date: null }); + }); + + it('re-pins the dates and extends the trip when inserting into a dated trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); + const [d1, d2, d3] = dayIdsInOrder(trip.id); + + let insertedId = 0; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + // date and notes are ignored next to a position, exactly as on REST. + arguments: { tripId: trip.id, position: 2, date: '2030-01-01', notes: 'ignored' }, + }); + insertedId = (parseToolResult(result) as { day: { id: number } }).day.id; + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1, insertedId, d2, d3]); + expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03', '2025-06-04']); + expect(dayRow(insertedId)).toMatchObject({ date: '2025-06-02', notes: null }); + expect(testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id)).toEqual({ end_date: '2025-06-04' }); + }); + + it('appends when position is omitted and still honours date and notes', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + + let appendedId = 0; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' }, + }); + appendedId = (parseToolResult(result) as { day: { id: number } }).day.id; + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, appendedId]); + expect(dayRow(appendedId)).toMatchObject({ date: '2025-06-15', notes: 'Arrival day' }); + }); + + it('broadcasts day:reordered for an insert and day:created for an append', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, position: 1 } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:reordered', expect.any(Object)); + expect(broadcastMock).not.toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object)); + + broadcastMock.mockClear(); + await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object)); + }); + }); + + it('refuses a position below 1', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id, position: 0 }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('Invalid arguments'); + }); + + expect(dayIdsInOrder(trip.id)).toHaveLength(1); + }); + + it('refuses an insert that would invert a stay, leaving the trip untouched', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); + const [d1, d2, d3] = dayIdsInOrder(trip.id); + const place = createPlace(testDb, trip.id); + // The stay ends on the last day, so pushing a day in front of its start + // cannot invert it; anchoring it the other way round is what does. + createDayAccommodation(testDb, trip.id, place.id, d3, d1); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id, position: 2 }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('accommodation'); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); + expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); + }); + + it('blocks demo user on an insert', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id, position: 1 } }); + expect(result.isError).toBe(true); + }); + expect(dayIdsInOrder(trip.id)).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// reorder_days +// --------------------------------------------------------------------------- + +describe('Tool: reorder_days', () => { + it('renumbers the days and carries each day content along', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id, { title: 'First' }); + const d2 = createDay(testDb, trip.id, { title: 'Second' }); + const d3 = createDay(testDb, trip.id, { title: 'Third' }); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, d3.id, place.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d3.id, d1.id, d2.id] }, + }); + expect(parseToolResult(result)).toEqual({ success: true }); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d3.id, d1.id, d2.id]); + expect(dayRow(d3.id)).toMatchObject({ day_number: 1, title: 'Third' }); + expect(testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id)) + .toEqual({ day_id: d3.id }); + }); + + it('keeps the dates pinned to their slots so the content moves across them', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); + const [d1, d2, d3] = dayIdsInOrder(trip.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d3, d1, d2] }, + }); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d3, d1, d2]); + expect(dayDatesInOrder(trip.id)).toEqual(['2025-06-01', '2025-06-02', '2025-06-03']); + expect(dayRow(d3).date).toBe('2025-06-01'); + }); + + it('broadcasts day:reordered with the ordered ids', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'reorder_days', arguments: { tripId: trip.id, orderedIds: [d2.id, d1.id] } }); + }); + + expect(broadcastMock).toHaveBeenCalledWith( + trip.id, + 'day:reordered', + expect.objectContaining({ orderedIds: [d2.id, d1.id] }), + ); + }); + + it('refuses a list that is not a permutation of the trip days', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + const d3 = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d2.id, d1.id] }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('permutation'); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id, d3.id]); + expect(broadcastMock).not.toHaveBeenCalled(); + }); + + it('refuses a day id from another trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const other = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + const foreign = createDay(testDb, other.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d1.id, foreign.id] }, + }); + expect(result.isError).toBe(true); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + expect(dayRow(foreign.id).day_number).toBe(1); + }); + + it('refuses a move that would make a stay end before it starts, and rolls back', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-03' }); + const [d1, d2, d3] = dayIdsInOrder(trip.id); + const place = createPlace(testDb, trip.id); + createDayAccommodation(testDb, trip.id, place.id, d1, d2); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d2, d1, d3] }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('accommodation'); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1, d2, d3]); + expect(broadcastMock).not.toHaveBeenCalled(); + }); + + it('refuses an empty list', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [] }, + }); + expect(result.isError).toBe(true); + expect((result.content as { text: string }[])[0].text).toContain('Invalid arguments'); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d2.id, d1.id] }, + }); + expect(result.isError).toBe(true); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + const d1 = createDay(testDb, trip.id); + const d2 = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_days', + arguments: { tripId: trip.id, orderedIds: [d2.id, d1.id] }, + }); + expect(result.isError).toBe(true); + }); + + expect(dayIdsInOrder(trip.id)).toEqual([d1.id, d2.id]); + }); +}); + // --------------------------------------------------------------------------- // trek://trips/{tripId}/days resource (moved from resources.test.ts) // ---------------------------------------------------------------------------