mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
8864edda18
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows.
918 lines
40 KiB
TypeScript
918 lines
40 KiB
TypeScript
/**
|
|
* Unit tests for MCP reservation tools: create_reservation, update_reservation,
|
|
* delete_reservation, link_hotel_accommodation.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
|
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, createBudgetItem, createDayAssignment, createDayAccommodation, createCategory, addTripMember } from '../../helpers/factories';
|
|
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ userId, withResources: false });
|
|
try { await fn(h); } finally { await h.cleanup(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// create_reservation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_reservation', () => {
|
|
it('creates a basic reservation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, title: 'Eiffel Tower Tour', type: 'tour' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.title).toBe('Eiffel Tower Tour');
|
|
expect(data.reservation.type).toBe('tour');
|
|
expect(data.reservation.status).toBe('pending');
|
|
});
|
|
});
|
|
|
|
it('creates a parking reservation (#1444)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, title: 'Airport Parking P1', type: 'parking' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.title).toBe('Airport Parking P1');
|
|
expect(data.reservation.type).toBe('parking');
|
|
});
|
|
});
|
|
|
|
it('creates a hotel reservation and links accommodation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
title: 'Grand Hotel Stay',
|
|
type: 'hotel',
|
|
place_id: hotel.id,
|
|
start_day_id: day1.id,
|
|
end_day_id: day2.id,
|
|
check_in: '15:00',
|
|
check_out: '11:00',
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.type).toBe('hotel');
|
|
expect(data.reservation.accommodation_id).not.toBeNull();
|
|
// accommodation was created
|
|
const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(data.reservation.accommodation_id) as any;
|
|
expect(acc.place_id).toBe(hotel.id);
|
|
expect(acc.check_in).toBe('15:00');
|
|
});
|
|
});
|
|
|
|
it('validates day_id belongs to trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip1 = createTrip(testDb, user.id);
|
|
const trip2 = createTrip(testDb, user.id);
|
|
const dayFromTrip2 = createDay(testDb, trip2.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip1.id, title: 'Flight', type: 'flight', day_id: dayFromTrip2.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('validates assignment_id belongs to trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip1 = createTrip(testDb, user.id);
|
|
const trip2 = createTrip(testDb, user.id);
|
|
const day2 = createDay(testDb, trip2.id);
|
|
const place2 = createPlace(testDb, trip2.id);
|
|
const assignment = createDayAssignment(testDb, day2.id, place2.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip1.id, title: 'Dinner', type: 'restaurant', assignment_id: assignment.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('broadcasts reservation:created event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'create_reservation', arguments: { tripId: trip.id, title: 'Bus', type: 'other' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('broadcasts accommodation:created for hotel type', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const hotel = createPlace(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'create_reservation', arguments: { tripId: trip.id, title: 'X', type: 'flight' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// update_reservation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: update_reservation', () => {
|
|
it('updates reservation fields', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id, { title: 'Old Title', type: 'flight' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_reservation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, title: 'New Title' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.title).toBe('New Title');
|
|
expect(data.reservation.type).toBe('flight'); // preserved
|
|
});
|
|
});
|
|
|
|
it('updates reservation status to confirmed', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_reservation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, status: 'confirmed' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.status).toBe('confirmed');
|
|
});
|
|
});
|
|
|
|
it('broadcasts reservation:updated event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'Updated' } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error for reservation not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: 99999, title: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('validates place_id belongs to trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip1 = createTrip(testDb, user.id);
|
|
const trip2 = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip1.id);
|
|
const placeFromTrip2 = createPlace(testDb, trip2.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_reservation',
|
|
arguments: { tripId: trip1.id, reservationId: reservation.id, place_id: placeFromTrip2.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'X' } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// delete_reservation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: delete_reservation', () => {
|
|
it('deletes a reservation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.success).toBe(true);
|
|
expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('cascades to accommodation when linked', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const hotel = createPlace(testDb, trip.id);
|
|
// Create reservation via tool so accommodation is linked
|
|
let reservationId: number;
|
|
await withHarness(user.id, async (h) => {
|
|
const r = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
reservationId = (parseToolResult(r) as any).reservation.id;
|
|
});
|
|
|
|
const accId = (testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(reservationId!) as any).accommodation_id;
|
|
expect(accId).not.toBeNull();
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId } });
|
|
});
|
|
|
|
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accId)).toBeUndefined();
|
|
});
|
|
|
|
it('broadcasts reservation:deleted event', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.any(Object));
|
|
// Nothing was linked, so no expense is announced gone.
|
|
expect(broadcastMock.mock.calls.some(c => c[1] === 'budget:deleted')).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('takes every linked expense with it and announces each one, as the REST route does (#2084)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
const fare = createBudgetItem(testDb, trip.id, { name: 'Fare' });
|
|
const luggage = createBudgetItem(testDb, trip.id, { name: 'Luggage' });
|
|
const unrelated = createBudgetItem(testDb, trip.id, { name: 'Coffee' });
|
|
testDb.prepare('UPDATE budget_items SET reservation_id = ? WHERE id IN (?, ?)').run(reservation.id, fare.id, luggage.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
|
|
expect(parseToolResult(result)).toEqual({ success: true });
|
|
});
|
|
expect(testDb.prepare('SELECT id FROM budget_items WHERE trip_id = ?').all(trip.id)).toEqual([{ id: unrelated.id }]);
|
|
const deleted = broadcastMock.mock.calls.filter(c => c[1] === 'budget:deleted').map(c => (c[2] as { itemId: number }).itemId);
|
|
expect(deleted).toEqual([fare.id, luggage.id]);
|
|
// The expenses go out before the booking, so no client is left holding a link to nothing.
|
|
const events = broadcastMock.mock.calls.map(c => c[1]);
|
|
expect(events.lastIndexOf('budget:deleted')).toBeLessThan(events.indexOf('reservation:deleted'));
|
|
});
|
|
|
|
it('returns error for reservation not found', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: 99999 } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const reservation = createReservation(testDb, trip.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// link_hotel_accommodation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: link_hotel_accommodation', () => {
|
|
it('creates new accommodation link for a hotel reservation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Ritz' });
|
|
const reservation = createReservation(testDb, trip.id, { type: 'hotel' });
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id, check_in: '14:00', check_out: '12:00' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.accommodation_id).not.toBeNull();
|
|
expect(data.accommodation_id).not.toBeNull();
|
|
// accommodation_id must be a clean integer, not a stringified float ("14.0").
|
|
expect(typeof data.reservation.accommodation_id).toBe('number');
|
|
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
|
|
expect(Number.isInteger(data.accommodation_id)).toBe(true);
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('updates existing accommodation link', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const day3 = createDay(testDb, trip.id, { day_number: 3 });
|
|
const hotel = createPlace(testDb, trip.id, { name: 'Hotel A' });
|
|
const hotel2 = createPlace(testDb, trip.id, { name: 'Hotel B' });
|
|
const reservation = createReservation(testDb, trip.id, { type: 'hotel' });
|
|
|
|
// First link
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
});
|
|
|
|
// Update link
|
|
await withHarness(user.id, async (h) => {
|
|
await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel2.id, start_day_id: day2.id, end_day_id: day3.id },
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
|
|
});
|
|
});
|
|
|
|
it('returns error for non-hotel reservation', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const place = createPlace(testDb, trip.id);
|
|
const reservation = createReservation(testDb, trip.id, { type: 'flight' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('validates place_id belongs to trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip1 = createTrip(testDb, user.id);
|
|
const trip2 = createTrip(testDb, user.id);
|
|
const day1 = createDay(testDb, trip1.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip1.id, { day_number: 2 });
|
|
const placeFromTrip2 = createPlace(testDb, trip2.id);
|
|
const reservation = createReservation(testDb, trip1.id, { type: 'hotel' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip1.id, reservationId: reservation.id, place_id: placeFromTrip2.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for non-member', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
const day1 = createDay(testDb, trip.id, { day_number: 1 });
|
|
const day2 = createDay(testDb, trip.id, { day_number: 2 });
|
|
const place = createPlace(testDb, trip.id);
|
|
const reservation = createReservation(testDb, trip.id, { type: 'hotel' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'link_hotel_accommodation',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// trek://trips/{tripId}/reservations resource (moved from the legacy
|
|
// registerResources into the DI-discovered ReservationsMcp)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Resource: trek://trips/{tripId}/reservations', () => {
|
|
it('returns reservations for a trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
createReservation(testDb, trip.id, { title: 'Flight to Paris', type: 'flight' });
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
|
const items = parseResourceResult(result) as { title: string }[];
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].title).toBe('Flight to Paris');
|
|
});
|
|
});
|
|
|
|
it('returns access denied for unauthorized trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
const trip = createTrip(testDb, other.id);
|
|
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
|
const data = parseResourceResult(result) as { error?: string };
|
|
expect(data.error).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_reservation_travelers (#1517)
|
|
//
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: set_reservation_travelers', () => {
|
|
async function makeBooking(h: McpHarness, tripId: number): Promise<number> {
|
|
const created = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId, type: 'event', title: 'Concert' },
|
|
});
|
|
return (parseToolResult(created) as any).reservation.id;
|
|
}
|
|
|
|
it('sets the travellers on a booking', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: friend } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, friend.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id, friend.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.travelers.map((t: any) => t.user_id).sort()).toEqual([owner.id, friend.id].sort());
|
|
const rows = testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId) as any[];
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('assigns a guest, who has no account to look up', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const guestResult = await h.client.callTool({
|
|
name: 'create_trip_guest',
|
|
arguments: { tripId: trip.id, name: 'Anna' },
|
|
});
|
|
const guestId = (parseToolResult(guestResult) as any).member.id;
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [guestId] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.travelers).toHaveLength(1);
|
|
expect(data.travelers[0].username).toBe('Anna');
|
|
expect(data.travelers[0].is_guest).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('replaces the previous list rather than adding to it', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: friend } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
addTripMember(testDb, trip.id, friend.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id, friend.id] },
|
|
});
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [friend.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.travelers.map((t: any) => t.user_id)).toEqual([friend.id]);
|
|
});
|
|
});
|
|
|
|
it('clears the list on an empty array', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
|
});
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [] },
|
|
});
|
|
expect((parseToolResult(result) as any).travelers).toEqual([]);
|
|
expect(testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it('drops a user who is not on the trip instead of attaching them', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: outsider } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id, outsider.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.travelers.map((t: any) => t.user_id)).toEqual([owner.id]);
|
|
// A caller that guessed an id for a name it could not resolve is told so
|
|
// rather than getting a plain success with a shorter list.
|
|
expect(data.ignored_user_ids).toEqual([outsider.id]);
|
|
});
|
|
});
|
|
|
|
it('says nothing about ignored ids when every id was on the trip', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.ignored_user_ids).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('broadcasts reservation:travelers-updated', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, trip.id);
|
|
broadcastMock.mockClear();
|
|
await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] },
|
|
});
|
|
expect(broadcastMock).toHaveBeenCalledWith(
|
|
trip.id,
|
|
'reservation:travelers-updated',
|
|
expect.objectContaining({ reservationId }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('404s a booking of another trip', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const ownTrip = createTrip(testDb, owner.id);
|
|
const otherTrip = createTrip(testDb, owner.id);
|
|
await withHarness(owner.id, async (h) => {
|
|
const reservationId = await makeBooking(h, otherTrip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: ownTrip.id, reservationId, user_ids: [owner.id] },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns access denied for a non-member', async () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: outsider } = createUser(testDb);
|
|
const trip = createTrip(testDb, owner.id);
|
|
let reservationId = 0;
|
|
await withHarness(owner.id, async (h) => { reservationId = await makeBooking(h, trip.id); });
|
|
await withHarness(outsider.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId, user_ids: [outsider.id] },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('blocks demo user', async () => {
|
|
process.env.DEMO_MODE = 'true';
|
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'set_reservation_travelers',
|
|
arguments: { tripId: trip.id, reservationId: 1, user_ids: [] },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The booking link and the end time
|
|
//
|
|
// Both are persisted on create and update and rendered by the planner; neither
|
|
// was in any tool schema, so an imported booking arrived without its
|
|
// confirmation link and a dinner could only carry a start.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Reservation tools: url and reservation_end_time', () => {
|
|
it('stores the booking link on create', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: {
|
|
tripId: trip.id, type: 'restaurant', title: 'Dinner',
|
|
url: 'https://example.com/booking/abc123',
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.url).toBe('https://example.com/booking/abc123');
|
|
const row = testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(data.reservation.id) as any;
|
|
expect(row.url).toBe('https://example.com/booking/abc123');
|
|
});
|
|
});
|
|
|
|
it('stores an end time on a non-transport booking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: {
|
|
tripId: trip.id, type: 'restaurant', title: 'Dinner',
|
|
reservation_time: '2025-06-01T19:00:00', reservation_end_time: '2025-06-01T21:30:00',
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
const row = testDb.prepare('SELECT reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(data.reservation.id) as any;
|
|
expect(row.reservation_time).toBe('2025-06-01T19:00:00');
|
|
expect(row.reservation_end_time).toBe('2025-06-01T21:30:00');
|
|
});
|
|
});
|
|
|
|
it('updates the link and the end time on an existing booking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, type: 'tour', title: 'Walking tour' },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
await h.client.callTool({
|
|
name: 'update_reservation',
|
|
arguments: {
|
|
tripId: trip.id, reservationId: reservation.id,
|
|
url: 'https://tours.example/booking/9', reservation_end_time: '2025-06-02T16:00:00',
|
|
},
|
|
});
|
|
const row = testDb.prepare('SELECT url, reservation_end_time FROM reservations WHERE id = ?').get(reservation.id) as any;
|
|
expect(row.url).toBe('https://tours.example/booking/9');
|
|
expect(row.reservation_end_time).toBe('2025-06-02T16:00:00');
|
|
});
|
|
});
|
|
|
|
it('refuses a javascript: link, like the REST contract does', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, type: 'other', title: 'Nope', url: 'javascript:alert(1)' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect(testDb.prepare('SELECT COUNT(*) AS n FROM reservations').get()).toEqual({ n: 0 });
|
|
});
|
|
});
|
|
|
|
it('refuses a javascript: link split by a control character', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, type: 'other', title: 'Nope', url: 'java\tscript:alert(1)' },
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('stores the booking link on a transport booking too', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id, type: 'bus', title: 'Zurich → Milan',
|
|
url: 'https://flix.example/booking/8891',
|
|
},
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8891' });
|
|
|
|
await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, url: 'https://flix.example/booking/8892' },
|
|
});
|
|
expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8892' });
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
});
|
|
});
|