Merge pull request #409 from mauriceboe/refactor/mcp-use-service-layer
refactor(mcp): replace direct DB access with service layer calls
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID, createHash } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { db } from '../db/database';
|
||||
import { User } from '../types';
|
||||
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
|
||||
@@ -74,36 +73,15 @@ function verifyToken(authHeader: string | undefined): User | null {
|
||||
|
||||
// Long-lived MCP API token (trek_...)
|
||||
if (token.startsWith('trek_')) {
|
||||
const hash = createHash('sha256').update(token).digest('hex');
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, u.email, u.role
|
||||
FROM mcp_tokens mt
|
||||
JOIN users u ON mt.user_id = u.id
|
||||
WHERE mt.token_hash = ?
|
||||
`).get(hash) as User | undefined;
|
||||
if (row) {
|
||||
// Update last_used_at (fire-and-forget, non-blocking)
|
||||
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
|
||||
return row;
|
||||
}
|
||||
return null;
|
||||
return verifyMcpToken(token);
|
||||
}
|
||||
|
||||
// Short-lived JWT
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||
).get(decoded.id) as User | undefined;
|
||||
return user || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return verifyJwtToken(token);
|
||||
}
|
||||
|
||||
export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined;
|
||||
if (!mcpAddon || !mcpAddon.enabled) {
|
||||
if (!isAddonEnabled('mcp')) {
|
||||
res.status(403).json({ error: 'MCP is not enabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
|
||||
const TRIP_SELECT = `
|
||||
SELECT t.*,
|
||||
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
||||
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
||||
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
|
||||
u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
|
||||
FROM trips t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
`;
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
|
||||
import { listDays, listAccommodations } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listItems as listPackingItems } from '../services/packingService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listNotes as listDayNotes } from '../services/dayNoteService';
|
||||
import { listNotes as listCollabNotes } from '../services/collabService';
|
||||
import { listCategories } from '../services/categoryService';
|
||||
import { listBucketList, listVisitedCountries } from '../services/atlasService';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -44,12 +43,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
'trek://trips',
|
||||
{ description: 'All trips the user owns or is a member of' },
|
||||
async (uri) => {
|
||||
const trips = db.prepare(`
|
||||
${TRIP_SELECT}
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0
|
||||
ORDER BY t.created_at DESC
|
||||
`).all({ userId });
|
||||
const trips = listTrips(userId, 0);
|
||||
return jsonContent(uri.href, trips);
|
||||
}
|
||||
);
|
||||
@@ -62,11 +56,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const trip = db.prepare(`
|
||||
${TRIP_SELECT}
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||
`).get({ userId, tripId: id });
|
||||
const trip = getTrip(id, userId);
|
||||
return jsonContent(uri.href, trip);
|
||||
}
|
||||
);
|
||||
@@ -80,35 +70,8 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
|
||||
const days = db.prepare(
|
||||
'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC'
|
||||
).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[];
|
||||
|
||||
const dayIds = days.map(d => d.id);
|
||||
const assignmentsByDay: Record<number, unknown[]> = {};
|
||||
|
||||
if (dayIds.length > 0) {
|
||||
const placeholders = dayIds.map(() => '?').join(',');
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
|
||||
p.id as place_id, p.name, p.address, p.lat, p.lng, p.category_id,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${placeholders})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
|
||||
|
||||
for (const a of assignments) {
|
||||
if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
|
||||
assignmentsByDay[a.day_id].push(a);
|
||||
}
|
||||
}
|
||||
|
||||
const result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
|
||||
return jsonContent(uri.href, result);
|
||||
const { days } = listDays(id);
|
||||
return jsonContent(uri.href, days);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -120,13 +83,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const places = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY p.created_at DESC
|
||||
`).all(id);
|
||||
const places = listPlaces(String(id), {});
|
||||
return jsonContent(uri.href, places);
|
||||
}
|
||||
);
|
||||
@@ -139,9 +96,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
||||
).all(id);
|
||||
const items = listBudgetItems(id);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
@@ -154,9 +109,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||
).all(id);
|
||||
const items = listPackingItems(id);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
@@ -169,14 +122,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.*, d.day_number, p.name as place_name
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
LEFT JOIN places p ON r.place_id = p.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`).all(id);
|
||||
const reservations = listReservations(id);
|
||||
return jsonContent(uri.href, reservations);
|
||||
}
|
||||
);
|
||||
@@ -190,9 +136,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
const tId = parseId(tripId);
|
||||
const dId = parseId(dayId);
|
||||
if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
|
||||
const notes = db.prepare(
|
||||
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||
).all(dId, tId);
|
||||
const notes = listDayNotes(dId, tId);
|
||||
return jsonContent(uri.href, notes);
|
||||
}
|
||||
);
|
||||
@@ -205,16 +149,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const accommodations = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng,
|
||||
ds.day_number as start_day_number, de.day_number as end_day_number
|
||||
FROM day_accommodations da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN days ds ON da.start_day_id = ds.id
|
||||
LEFT JOIN days de ON da.end_day_id = de.id
|
||||
WHERE da.trip_id = ?
|
||||
ORDER BY ds.day_number ASC
|
||||
`).all(id);
|
||||
const accommodations = listAccommodations(id);
|
||||
return jsonContent(uri.href, accommodations);
|
||||
}
|
||||
);
|
||||
@@ -227,20 +162,10 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(id) as { user_id: number } | undefined;
|
||||
if (!trip) return accessDenied(uri.href);
|
||||
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id) as Record<string, unknown> | undefined;
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.username, u.avatar, tm.added_at
|
||||
FROM trip_members tm
|
||||
JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.trip_id = ?
|
||||
ORDER BY tm.added_at ASC
|
||||
`).all(id);
|
||||
return jsonContent(uri.href, {
|
||||
owner: owner ? { ...owner, role: 'owner' } : null,
|
||||
members,
|
||||
});
|
||||
const ownerRow = getTripOwner(id);
|
||||
if (!ownerRow) return accessDenied(uri.href);
|
||||
const { owner, members } = listMembers(id, ownerRow.user_id);
|
||||
return jsonContent(uri.href, { owner, members });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -252,13 +177,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const notes = db.prepare(`
|
||||
SELECT cn.*, u.username
|
||||
FROM collab_notes cn
|
||||
JOIN users u ON cn.user_id = u.id
|
||||
WHERE cn.trip_id = ?
|
||||
ORDER BY cn.pinned DESC, cn.updated_at DESC
|
||||
`).all(id);
|
||||
const notes = listCollabNotes(id);
|
||||
return jsonContent(uri.href, notes);
|
||||
}
|
||||
);
|
||||
@@ -269,9 +188,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
'trek://categories',
|
||||
{ description: 'All available place categories (id, name, color, icon) for use when creating places' },
|
||||
async (uri) => {
|
||||
const categories = db.prepare(
|
||||
'SELECT id, name, color, icon FROM categories ORDER BY name ASC'
|
||||
).all();
|
||||
const categories = listCategories();
|
||||
return jsonContent(uri.href, categories);
|
||||
}
|
||||
);
|
||||
@@ -282,9 +199,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
'trek://bucket-list',
|
||||
{ description: 'Your personal travel bucket list' },
|
||||
async (uri) => {
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId);
|
||||
const items = listBucketList(userId);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
@@ -295,9 +210,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
'trek://visited-countries',
|
||||
{ description: 'Countries you have marked as visited in Atlas' },
|
||||
async (uri) => {
|
||||
const countries = db.prepare(
|
||||
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId);
|
||||
const countries = listVisitedCountries(userId);
|
||||
return jsonContent(uri.href, countries);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db, canAccessTrip, isOwner } from '../db/database';
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { isDemoUser } from '../services/authService';
|
||||
import {
|
||||
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
|
||||
isOwner, verifyTripAccess,
|
||||
} from '../services/tripService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../services/placeService';
|
||||
import { listCategories } from '../services/categoryService';
|
||||
import {
|
||||
dayExists, placeExists, createAssignment, assignmentExistsInDay,
|
||||
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
|
||||
} from '../services/assignmentService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||
import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService';
|
||||
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
|
||||
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService';
|
||||
import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService';
|
||||
import { createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote } from '../services/collabService';
|
||||
import {
|
||||
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
|
||||
} from '../services/atlasService';
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const MAX_TRIP_DAYS = 90;
|
||||
|
||||
function isDemoUser(userId: number): boolean {
|
||||
if (process.env.DEMO_MODE !== 'true') return false;
|
||||
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return user?.email === 'demo@nomad.app';
|
||||
}
|
||||
const MAX_MCP_TRIP_DAYS = 90;
|
||||
|
||||
function demoDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
|
||||
@@ -26,25 +37,6 @@ function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
/** Create days for a newly created trip (fresh insert, no existing days). */
|
||||
function createDaysForNewTrip(tripId: number | bigint, startDate: string | null, endDate: string | null): void {
|
||||
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
||||
if (startDate && endDate) {
|
||||
const [sy, sm, sd] = startDate.split('-').map(Number);
|
||||
const [ey, em, ed] = endDate.split('-').map(Number);
|
||||
const startMs = Date.UTC(sy, sm - 1, sd);
|
||||
const endMs = Date.UTC(ey, em - 1, ed);
|
||||
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
const d = new Date(startMs + i * MS_PER_DAY);
|
||||
const date = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
insert.run(tripId, i + 1, date);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < 7; i++) insert.run(tripId, i + 1, null);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTools(server: McpServer, userId: number): void {
|
||||
// --- TRIPS ---
|
||||
|
||||
@@ -75,14 +67,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
|
||||
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
|
||||
}
|
||||
const trip = db.transaction(() => {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId, title, description || null, start_date || null, end_date || null, currency || 'EUR');
|
||||
const tripId = result.lastInsertRowid as number;
|
||||
createDaysForNewTrip(tripId, start_date || null, end_date || null);
|
||||
return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
|
||||
})();
|
||||
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
|
||||
return ok({ trip });
|
||||
}
|
||||
);
|
||||
@@ -113,21 +98,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
const existing = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> & { title: string; description: string; start_date: string; end_date: string; currency: string } | undefined;
|
||||
if (!existing) return noAccess();
|
||||
db.prepare(
|
||||
'UPDATE trips SET title = ?, description = ?, start_date = ?, end_date = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(
|
||||
title ?? existing.title,
|
||||
description !== undefined ? description : existing.description,
|
||||
start_date !== undefined ? start_date : existing.start_date,
|
||||
end_date !== undefined ? end_date : existing.end_date,
|
||||
currency ?? existing.currency,
|
||||
tripId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
|
||||
broadcast(tripId, 'trip:updated', { trip: updated });
|
||||
return ok({ trip: updated });
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
broadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -142,7 +115,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!isOwner(tripId, userId)) return noAccess();
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
|
||||
deleteTrip(tripId, userId, 'user');
|
||||
return ok({ success: true, tripId });
|
||||
}
|
||||
);
|
||||
@@ -156,18 +129,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
},
|
||||
async ({ include_archived }) => {
|
||||
const trips = db.prepare(`
|
||||
SELECT t.*, u.username as owner_username,
|
||||
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
|
||||
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
|
||||
CASE WHEN t.user_id = ? THEN 1 ELSE 0 END as is_owner
|
||||
FROM trips t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
|
||||
AND (? = 1 OR t.is_archived = 0)
|
||||
ORDER BY t.updated_at DESC
|
||||
`).all(userId, userId, userId, include_archived ? 1 : 0);
|
||||
const trips = listTrips(userId, include_archived ? null : 0);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
@@ -196,11 +158,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, google_place_id || null, osm_id || null, notes || null, website || null, phone || null, 'walking');
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
||||
broadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -226,25 +184,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE places SET
|
||||
name = ?, description = ?, lat = ?, lng = ?, address = ?, notes = ?, website = ?, phone = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? existing.name,
|
||||
description !== undefined ? description : existing.description,
|
||||
lat !== undefined ? lat : existing.lat,
|
||||
lng !== undefined ? lng : existing.lng,
|
||||
address !== undefined ? address : existing.address,
|
||||
notes !== undefined ? notes : existing.notes,
|
||||
website !== undefined ? website : existing.website,
|
||||
phone !== undefined ? phone : existing.phone,
|
||||
placeId
|
||||
);
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
|
||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
broadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -262,9 +203,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
broadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -279,7 +219,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const categories = db.prepare('SELECT id, name, color, icon FROM categories ORDER BY name ASC').all();
|
||||
const categories = listCategories();
|
||||
return ok({ categories });
|
||||
}
|
||||
);
|
||||
@@ -333,20 +273,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, placeId, orderIndex, notes || null);
|
||||
const assignment = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address, p.lat, p.lng
|
||||
FROM day_assignments da JOIN places p ON da.place_id = p.id
|
||||
WHERE da.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
broadcast(tripId, 'assignment:created', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
@@ -365,11 +294,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignment = db.prepare(
|
||||
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
|
||||
).get(assignmentId, dayId, tripId);
|
||||
if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignmentId);
|
||||
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -392,12 +319,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, category || 'Other', name, total_price, note || null, sortOrder);
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
broadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -415,9 +337,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(itemId);
|
||||
const deleted = deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
broadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -438,12 +359,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, name, 0, category || 'General', sortOrder);
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
||||
broadcast(tripId, 'packing:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -462,12 +378,10 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('UPDATE packing_items SET checked = ? WHERE id = ?').run(checked ? 1 : 0, itemId);
|
||||
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'packing:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
broadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -483,9 +397,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(itemId);
|
||||
const deleted = deletePackingItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
broadcast(tripId, 'packing:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -519,43 +432,28 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
if (day_id) {
|
||||
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (place_id) {
|
||||
if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (start_day_id) {
|
||||
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (end_day_id) {
|
||||
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (assignment_id) {
|
||||
if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (day_id && !getDay(day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
||||
if (place_id && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const reservation = db.transaction(() => {
|
||||
let accommodationId: number | null = null;
|
||||
if (type === 'hotel' && place_id && start_day_id && end_day_id) {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation_number || null);
|
||||
accommodationId = accResult.lastInsertRowid as number;
|
||||
}
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, accommodation_id, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, title, type, reservation_time || null, location || null, confirmation_number || null, notes || null, day_id || null, place_id || null, assignment_id || null, accommodationId, 'pending');
|
||||
return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
|
||||
})();
|
||||
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
if (type === 'hotel' && place_id && start_day_id && end_day_id) {
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
broadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
broadcast(tripId, 'reservation:created', { reservation });
|
||||
@@ -575,16 +473,10 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as { id: number; accommodation_id: number | null } | undefined;
|
||||
if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
db.transaction(() => {
|
||||
if (reservation.accommodation_id) {
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
|
||||
}
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
|
||||
})();
|
||||
if (reservation.accommodation_id) {
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id });
|
||||
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (accommodationDeleted) {
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
||||
}
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
@@ -608,36 +500,28 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (reservation.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
const current = getReservation(reservationId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
|
||||
if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
|
||||
if (!placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId))
|
||||
if (!getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId))
|
||||
if (!getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
let accommodationId = reservation.accommodation_id as number | null;
|
||||
const isNewAccommodation = !accommodationId;
|
||||
db.transaction(() => {
|
||||
if (accommodationId) {
|
||||
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(place_id, start_day_id, end_day_id, check_in || null, check_out || null, accommodationId);
|
||||
} else {
|
||||
const accResult = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, reservation.confirmation_number || null);
|
||||
accommodationId = accResult.lastInsertRowid as number;
|
||||
}
|
||||
db.prepare('UPDATE reservations SET place_id = ?, accommodation_id = ? WHERE id = ?')
|
||||
.run(place_id, accommodationId, reservationId);
|
||||
})();
|
||||
const isNewAccommodation = !current.accommodation_id;
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
place_id,
|
||||
type: current.type,
|
||||
status: current.status as string,
|
||||
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
||||
}, current);
|
||||
|
||||
broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updated });
|
||||
return ok({ reservation: updated, accommodation_id: accommodationId });
|
||||
broadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -657,28 +541,15 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, assignmentId, place_time, end_time }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignment = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da
|
||||
JOIN days d ON da.day_id = d.id
|
||||
WHERE da.id = ? AND d.trip_id = ?
|
||||
`).get(assignmentId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||
.run(
|
||||
place_time !== undefined ? place_time : assignment.assignment_time,
|
||||
end_time !== undefined ? end_time : assignment.assignment_end_time,
|
||||
assignmentId
|
||||
);
|
||||
const updated = db.prepare(`
|
||||
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
|
||||
da.assignment_time, da.assignment_end_time,
|
||||
p.id as place_id, p.name, p.address
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.id = ?
|
||||
`).get(assignmentId);
|
||||
broadcast(tripId, 'assignment:updated', { assignment: updated });
|
||||
return ok({ assignment: updated });
|
||||
const existing = getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const assignment = updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : (existing as any).assignment_time,
|
||||
end_time !== undefined ? end_time : (existing as any).assignment_end_time
|
||||
);
|
||||
broadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -695,10 +566,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, title }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
db.prepare('UPDATE days SET title = ? WHERE id = ?').run(title, dayId);
|
||||
const updated = db.prepare('SELECT * FROM days WHERE id = ?').get(dayId);
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
|
||||
broadcast(tripId, 'day:updated', { day: updated });
|
||||
return ok({ day: updated });
|
||||
}
|
||||
@@ -727,39 +597,21 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record<string, unknown> | undefined;
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
|
||||
if (place_id != null) {
|
||||
if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (assignment_id != null) {
|
||||
if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
}
|
||||
if (place_id != null && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
db.prepare(`
|
||||
UPDATE reservations SET
|
||||
title = ?, type = ?, reservation_time = ?, location = ?,
|
||||
confirmation_number = ?, notes = ?, status = ?,
|
||||
place_id = ?, assignment_id = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title ?? existing.title,
|
||||
type ?? existing.type,
|
||||
reservation_time !== undefined ? reservation_time : existing.reservation_time,
|
||||
location !== undefined ? location : existing.location,
|
||||
confirmation_number !== undefined ? confirmation_number : existing.confirmation_number,
|
||||
notes !== undefined ? notes : existing.notes,
|
||||
status ?? existing.status,
|
||||
place_id !== undefined ? place_id : existing.place_id,
|
||||
assignment_id !== undefined ? assignment_id : existing.assignment_id,
|
||||
reservationId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updated });
|
||||
return ok({ reservation: updated });
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title, type, reservation_time, location, confirmation_number, notes, status,
|
||||
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
||||
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
||||
}, existing);
|
||||
broadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -783,24 +635,10 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE budget_items SET
|
||||
name = ?, category = ?, total_price = ?, persons = ?, days = ?, note = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? existing.name,
|
||||
category ?? existing.category,
|
||||
total_price !== undefined ? total_price : existing.total_price,
|
||||
persons !== undefined ? persons : existing.persons,
|
||||
days !== undefined ? days : existing.days,
|
||||
note !== undefined ? note : existing.note,
|
||||
itemId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'budget:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
broadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -820,16 +658,11 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, itemId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('UPDATE packing_items SET name = ?, category = ? WHERE id = ?').run(
|
||||
name ?? existing.name,
|
||||
category ?? existing.category,
|
||||
itemId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'packing:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
||||
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
broadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -848,13 +681,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||
const updateMany = db.transaction((ids: number[]) => {
|
||||
ids.forEach((id, index) => update.run(index, id, dayId));
|
||||
});
|
||||
updateMany(assignmentIds);
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
reorderAssignments(dayId, assignmentIds);
|
||||
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
@@ -872,106 +700,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
|
||||
if (!trip) return noAccess();
|
||||
|
||||
// Members
|
||||
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number);
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.username, u.avatar, tm.added_at
|
||||
FROM trip_members tm JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record<string, unknown> & { id: number })[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
const assignmentsByDay: Record<number, unknown[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const placeholders = dayIds.map(() => '?').join(',');
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
|
||||
p.id as place_id, p.name, p.address, p.lat, p.lng,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
c.name as category_name, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${placeholders})
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
|
||||
for (const a of assignments) {
|
||||
if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
|
||||
assignmentsByDay[a.day_id].push(a);
|
||||
}
|
||||
}
|
||||
// Day notes
|
||||
const dayNotesByDay: Record<number, unknown[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const placeholders = dayIds.map(() => '?').join(',');
|
||||
const dayNotes = db.prepare(`
|
||||
SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC
|
||||
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
|
||||
for (const n of dayNotes) {
|
||||
if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = [];
|
||||
dayNotesByDay[n.day_id].push(n);
|
||||
}
|
||||
}
|
||||
|
||||
const daysWithAssignments = days.map(d => ({
|
||||
...d,
|
||||
assignments: assignmentsByDay[d.id] || [],
|
||||
notes: dayNotesByDay[d.id] || [],
|
||||
}));
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number
|
||||
FROM day_accommodations da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN days ds ON da.start_day_id = ds.id
|
||||
LEFT JOIN days de ON da.end_day_id = de.id
|
||||
WHERE da.trip_id = ?
|
||||
ORDER BY ds.day_number ASC
|
||||
`).all(tripId);
|
||||
|
||||
// Budget summary
|
||||
const budgetStats = db.prepare(`
|
||||
SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total
|
||||
FROM budget_items WHERE trip_id = ?
|
||||
`).get(tripId) as { item_count: number; total: number };
|
||||
|
||||
// Packing summary
|
||||
const packingStats = db.prepare(`
|
||||
SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked
|
||||
FROM packing_items WHERE trip_id = ?
|
||||
`).get(tripId) as { total: number; checked: number };
|
||||
|
||||
// Upcoming reservations (all, sorted by time)
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.*, d.day_number
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`).all(tripId);
|
||||
|
||||
// Collab notes
|
||||
const collabNotes = db.prepare(
|
||||
'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC'
|
||||
).all(tripId);
|
||||
|
||||
return ok({
|
||||
trip,
|
||||
members: { owner, collaborators: members },
|
||||
days: daysWithAssignments,
|
||||
accommodations,
|
||||
budget: { ...budgetStats, currency: trip.currency },
|
||||
packing: packingStats,
|
||||
reservations,
|
||||
collab_notes: collabNotes,
|
||||
});
|
||||
const summary = getTripSummary(tripId);
|
||||
if (!summary) return noAccess();
|
||||
return ok(summary);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -991,10 +722,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ name, lat, lng, country_code, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = db.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId, name, lat ?? null, lng ?? null, country_code || null, notes || null);
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
||||
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
@@ -1009,9 +737,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const item = db.prepare('SELECT id FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId);
|
||||
const deleted = deleteBucketItem(userId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
@@ -1028,7 +755,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, country_code.toUpperCase());
|
||||
markCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
@@ -1043,7 +770,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, country_code.toUpperCase());
|
||||
unmarkCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
@@ -1065,11 +792,7 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, title, content, category, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, userId, title, content || null, category || 'General', color || '#6366f1');
|
||||
const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid);
|
||||
const note = createCollabNote(tripId, userId, { title, content, category, color });
|
||||
broadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
@@ -1092,26 +815,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, noteId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE collab_notes SET
|
||||
title = CASE WHEN ? THEN ? ELSE title END,
|
||||
content = CASE WHEN ? THEN ? ELSE content END,
|
||||
category = CASE WHEN ? THEN ? ELSE category END,
|
||||
color = CASE WHEN ? THEN ? ELSE color END,
|
||||
pinned = CASE WHEN ? THEN ? ELSE pinned END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title !== undefined ? 1 : 0, title !== undefined ? title : null,
|
||||
content !== undefined ? 1 : 0, content !== undefined ? content : null,
|
||||
category !== undefined ? 1 : 0, category !== undefined ? category : null,
|
||||
color !== undefined ? 1 : 0, color !== undefined ? color : null,
|
||||
pinned !== undefined ? 1 : 0, pinned !== undefined ? (pinned ? 1 : 0) : null,
|
||||
noteId
|
||||
);
|
||||
const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(noteId);
|
||||
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
broadcast(tripId, 'collab:note:updated', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
@@ -1129,19 +834,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
const noteFiles = db.prepare('SELECT filename FROM trip_files WHERE note_id = ?').all(noteId) as { filename: string }[];
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
for (const f of noteFiles) {
|
||||
const resolved = path.resolve(path.join(uploadsDir, 'files', f.filename));
|
||||
if (!resolved.startsWith(uploadsDir)) continue;
|
||||
try { fs.unlinkSync(resolved); } catch {}
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId);
|
||||
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId);
|
||||
})();
|
||||
const deleted = deleteCollabNote(tripId, noteId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
broadcast(tripId, 'collab:note:deleted', { noteId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
@@ -1164,12 +858,8 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(dayId, tripId, text.trim(), time || null, icon || '📝', 9999);
|
||||
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
|
||||
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const note = createDayNote(dayId, tripId, text, time, icon);
|
||||
broadcast(tripId, 'dayNote:created', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
@@ -1191,17 +881,11 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, noteId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId) as Record<string, unknown> | undefined;
|
||||
const existing = getDayNote(noteId, dayId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ? WHERE id = ?').run(
|
||||
text !== undefined ? text.trim() : existing.text,
|
||||
time !== undefined ? time : existing.time,
|
||||
icon ?? existing.icon,
|
||||
noteId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(noteId);
|
||||
broadcast(tripId, 'dayNote:updated', { dayId, note: updated });
|
||||
return ok({ note: updated });
|
||||
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
|
||||
broadcast(tripId, 'dayNote:updated', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1218,9 +902,9 @@ export function registerTools(server: McpServer, userId: number): void {
|
||||
async ({ tripId, dayId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId);
|
||||
const note = getDayNote(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM day_notes WHERE id = ?').run(noteId);
|
||||
deleteDayNote(noteId);
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -463,6 +463,11 @@ export function deleteTemplateItem(itemId: string) {
|
||||
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function isAddonEnabled(addonId: string): boolean {
|
||||
const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined;
|
||||
return !!addon?.enabled;
|
||||
}
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
|
||||
|
||||
@@ -35,8 +35,11 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
participants,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
|
||||
@@ -327,6 +327,12 @@ export function getCountryPlaces(userId: number, code: string) {
|
||||
|
||||
// ── Mark / unmark country ───────────────────────────────────────────────────
|
||||
|
||||
export function listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
|
||||
return db.prepare(
|
||||
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as { country_code: string; created_at: string }[];
|
||||
}
|
||||
|
||||
export function markCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
|
||||
}
|
||||
|
||||
@@ -988,3 +988,38 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
|
||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
||||
return { token };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP auth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isDemoUser(userId: number): boolean {
|
||||
if (process.env.DEMO_MODE !== 'true') return false;
|
||||
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return user?.email === 'demo@nomad.app';
|
||||
}
|
||||
|
||||
export function verifyMcpToken(rawToken: string): User | null {
|
||||
const hash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, u.email, u.role
|
||||
FROM mcp_tokens mt
|
||||
JOIN users u ON mt.user_id = u.id
|
||||
WHERE mt.token_hash = ?
|
||||
`).get(hash) as User | undefined;
|
||||
if (row) {
|
||||
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
|
||||
return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function verifyJwtToken(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
|
||||
return user || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ export function getDay(id: string | number, tripId: string | number) {
|
||||
return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
|
||||
}
|
||||
|
||||
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) {
|
||||
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
|
||||
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(
|
||||
fields.notes || null,
|
||||
fields.title !== undefined ? fields.title : current.title,
|
||||
'title' in fields ? (fields.title ?? null) : current.title,
|
||||
id
|
||||
);
|
||||
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
|
||||
|
||||
@@ -56,8 +56,11 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
participants: participants || [],
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
|
||||
@@ -2,6 +2,11 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db, canAccessTrip, isOwner } from '../db/database';
|
||||
import { Trip, User } from '../types';
|
||||
import { listDays, listAccommodations } from './dayService';
|
||||
import { listBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from './packingService';
|
||||
import { listReservations } from './reservationService';
|
||||
import { listNotes as listCollabNotes } from './collabService';
|
||||
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const MAX_TRIP_DAYS = 365;
|
||||
@@ -27,7 +32,7 @@ export { isOwner };
|
||||
|
||||
// ── Day generation ────────────────────────────────────────────────────────
|
||||
|
||||
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
|
||||
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number) {
|
||||
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
@@ -56,7 +61,7 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
||||
const [ey, em, ed] = endDate.split('-').map(Number);
|
||||
const startMs = Date.UTC(sy, sm - 1, sd);
|
||||
const endMs = Date.UTC(ey, em - 1, ed);
|
||||
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
|
||||
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS);
|
||||
|
||||
const targetDates: string[] = [];
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
@@ -110,7 +115,15 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
||||
|
||||
// ── Trip CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function listTrips(userId: number, archived: number) {
|
||||
export function listTrips(userId: number, archived: number | null) {
|
||||
if (archived === null) {
|
||||
return db.prepare(`
|
||||
${TRIP_SELECT}
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
|
||||
ORDER BY t.created_at DESC
|
||||
`).all({ userId });
|
||||
}
|
||||
return db.prepare(`
|
||||
${TRIP_SELECT}
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
|
||||
@@ -128,7 +141,7 @@ interface CreateTripData {
|
||||
reminder_days?: number;
|
||||
}
|
||||
|
||||
export function createTrip(userId: number, data: CreateTripData) {
|
||||
export function createTrip(userId: number, data: CreateTripData, maxDays?: number) {
|
||||
const rd = data.reminder_days !== undefined
|
||||
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
|
||||
: 3;
|
||||
@@ -139,7 +152,7 @@ export function createTrip(userId: number, data: CreateTripData) {
|
||||
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
|
||||
|
||||
const tripId = result.lastInsertRowid;
|
||||
generateDays(tripId, data.start_date || null, data.end_date || null);
|
||||
generateDays(tripId, data.start_date || null, data.end_date || null, maxDays);
|
||||
|
||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
|
||||
return { trip, tripId: Number(tripId), reminderDays: rd };
|
||||
@@ -409,6 +422,49 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
return { ics, filename: `${safeFilename}.ics` };
|
||||
}
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
export function getTripSummary(tripId: number) {
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
|
||||
if (!trip) return null;
|
||||
|
||||
const ownerRow = getTripOwner(tripId);
|
||||
if (!ownerRow) return null;
|
||||
const { owner, members } = listMembers(tripId, ownerRow.user_id);
|
||||
|
||||
const { days: rawDays } = listDays(tripId);
|
||||
const days = rawDays.map(({ notes_items, ...day }) => ({ ...day, notes: notes_items }));
|
||||
|
||||
const accommodations = listAccommodations(tripId);
|
||||
|
||||
const budgetItems = listBudgetItems(tripId);
|
||||
const budget = {
|
||||
item_count: budgetItems.length,
|
||||
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
currency: trip.currency,
|
||||
};
|
||||
|
||||
const packingItems = listPackingItems(tripId);
|
||||
const packing = {
|
||||
total: packingItems.length,
|
||||
checked: (packingItems as { checked: number }[]).filter(i => i.checked).length,
|
||||
};
|
||||
|
||||
const reservations = listReservations(tripId);
|
||||
const collab_notes = listCollabNotes(tripId);
|
||||
|
||||
return {
|
||||
trip,
|
||||
members: { owner, collaborators: members },
|
||||
days,
|
||||
accommodations,
|
||||
budget,
|
||||
packing,
|
||||
reservations,
|
||||
collab_notes,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Custom error types ────────────────────────────────────────────────────
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
|
||||
@@ -259,6 +259,201 @@ export interface TestInviteToken {
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayNote {
|
||||
id: number;
|
||||
day_id: number;
|
||||
trip_id: number;
|
||||
text: string;
|
||||
time: string | null;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function createDayNote(
|
||||
db: Database.Database,
|
||||
dayId: number,
|
||||
tripId: number,
|
||||
overrides: Partial<{ text: string; time: string; icon: string }> = {}
|
||||
): TestDayNote {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, 9999)'
|
||||
).run(dayId, tripId, overrides.text ?? 'Test note', overrides.time ?? null, overrides.icon ?? '📝');
|
||||
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid) as TestDayNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collab Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestCollabNote {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
user_id: number;
|
||||
title: string;
|
||||
content: string | null;
|
||||
category: string;
|
||||
color: string;
|
||||
pinned: number;
|
||||
}
|
||||
|
||||
export function createCollabNote(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
userId: number,
|
||||
overrides: Partial<{ title: string; content: string; category: string; color: string }> = {}
|
||||
): TestCollabNote {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
userId,
|
||||
overrides.title ?? 'Test Note',
|
||||
overrides.content ?? null,
|
||||
overrides.category ?? 'General',
|
||||
overrides.color ?? '#6366f1'
|
||||
);
|
||||
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayAssignment {
|
||||
id: number;
|
||||
day_id: number;
|
||||
place_id: number;
|
||||
order_index: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export function createDayAssignment(
|
||||
db: Database.Database,
|
||||
dayId: number,
|
||||
placeId: number,
|
||||
overrides: Partial<{ order_index: number; notes: string }> = {}
|
||||
): TestDayAssignment {
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
||||
const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, placeId, orderIndex, overrides.notes ?? null);
|
||||
return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bucket List
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestBucketListItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
country_code: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export function createBucketListItem(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ name: string; lat: number; lng: number; country_code: string; notes: string }> = {}
|
||||
): TestBucketListItem {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
userId,
|
||||
overrides.name ?? 'Test Destination',
|
||||
overrides.lat ?? null,
|
||||
overrides.lng ?? null,
|
||||
overrides.country_code ?? null,
|
||||
overrides.notes ?? null
|
||||
);
|
||||
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid) as TestBucketListItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visited Countries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createVisitedCountry(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
countryCode: string
|
||||
): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode.toUpperCase());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Accommodations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestDayAccommodation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
place_id: number;
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_out: string | null;
|
||||
}
|
||||
|
||||
export function createDayAccommodation(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
startDayId: number,
|
||||
endDayId: number,
|
||||
overrides: Partial<{ check_in: string; check_out: string; confirmation: string }> = {}
|
||||
): TestDayAccommodation {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
placeId,
|
||||
startDayId,
|
||||
endDayId,
|
||||
overrides.check_in ?? null,
|
||||
overrides.check_out ?? null,
|
||||
overrides.confirmation ?? null
|
||||
);
|
||||
return db.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(result.lastInsertRowid) as TestDayAccommodation;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export interface TestMcpToken {
|
||||
id: number;
|
||||
tokenHash: string;
|
||||
rawToken: string;
|
||||
}
|
||||
|
||||
export function createMcpToken(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: Partial<{ name: string; rawToken: string }> = {}
|
||||
): TestMcpToken {
|
||||
const rawToken = overrides.rawToken ?? `trek_test_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const tokenPrefix = rawToken.slice(0, 12);
|
||||
const result = db.prepare(
|
||||
'INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, ?, ?, ?)'
|
||||
).run(userId, tokenHash, tokenPrefix, overrides.name ?? 'Test Token');
|
||||
return { id: result.lastInsertRowid as number, tokenHash, rawToken };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createInviteToken(
|
||||
db: Database.Database,
|
||||
overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {}
|
||||
|
||||
68
server/tests/helpers/mcp-harness.ts
Normal file
68
server/tests/helpers/mcp-harness.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* MCP test harness.
|
||||
*
|
||||
* Creates an McpServer + MCP Client connected via InMemoryTransport for unit testing
|
||||
* tools and resources without HTTP overhead.
|
||||
*
|
||||
* Usage:
|
||||
* const harness = await createMcpHarness({ userId, registerTools: true });
|
||||
* const result = await harness.client.callTool({ name: 'create_trip', arguments: { title: 'Test' } });
|
||||
* await harness.cleanup();
|
||||
*/
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import { registerResources } from '../../src/mcp/resources';
|
||||
import { registerTools } from '../../src/mcp/tools';
|
||||
|
||||
export interface McpHarness {
|
||||
client: Client;
|
||||
server: McpServer;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface McpHarnessOptions {
|
||||
userId: number;
|
||||
/** Register read-only resources (default: true) */
|
||||
withResources?: boolean;
|
||||
/** Register read-write tools (default: true) */
|
||||
withTools?: boolean;
|
||||
}
|
||||
|
||||
export async function createMcpHarness(options: McpHarnessOptions): Promise<McpHarness> {
|
||||
const { userId, withResources = true, withTools = true } = options;
|
||||
|
||||
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
|
||||
|
||||
if (withResources) registerResources(server, userId);
|
||||
if (withTools) registerTools(server, userId);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const cleanup = async () => {
|
||||
try { await client.close(); } catch { /* ignore */ }
|
||||
try { await server.close(); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
return { client, server, cleanup };
|
||||
}
|
||||
|
||||
/** Parse JSON from a callTool result (first text content item). */
|
||||
export function parseToolResult(result: Awaited<ReturnType<Client['callTool']>>): unknown {
|
||||
const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined;
|
||||
if (!text) throw new Error('No text content in tool result');
|
||||
return JSON.parse(text.text);
|
||||
}
|
||||
|
||||
/** Parse JSON from a readResource result (first content item). */
|
||||
export function parseResourceResult(result: Awaited<ReturnType<Client['readResource']>>): unknown {
|
||||
const item = result.contents[0] as { text?: string } | undefined;
|
||||
if (!item?.text) throw new Error('No text content in resource result');
|
||||
return JSON.parse(item.text);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Covers MCP-001 to MCP-013.
|
||||
*
|
||||
* The MCP endpoint uses JWT auth and server-sent events / streaming HTTP.
|
||||
* Tests focus on authentication and basic rejection behavior.
|
||||
* Tests cover authentication, session management, rate limiting, and API token auth.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -47,6 +47,8 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -62,6 +64,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
closeMcpSessions();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -130,3 +133,160 @@ describe('MCP session init', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP API token auth', () => {
|
||||
it('MCP-002 — POST /mcp with valid trek_ API token authenticates successfully', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { rawToken } = createMcpToken(testDb, user.id);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${rawToken}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('MCP-002 — last_used_at is updated on token use', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { rawToken, id: tokenId } = createMcpToken(testDb, user.id);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const before = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
|
||||
|
||||
await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${rawToken}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
|
||||
const after = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at;
|
||||
expect(after).not.toBeNull();
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('MCP — POST /mcp with unknown trek_ token returns 401', async () => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', 'Bearer trek_totally_fake_token_not_in_db')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('MCP — POST /mcp with no Authorization header returns 401', async () => {
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP session management', () => {
|
||||
async function createSession(userId: number): Promise<string> {
|
||||
const token = generateToken(userId);
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
expect(res.status).toBe(200);
|
||||
const sessionId = res.headers['mcp-session-id'];
|
||||
expect(sessionId).toBeTruthy();
|
||||
return sessionId as string;
|
||||
}
|
||||
|
||||
it('MCP-003 — session limit of 5 per user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
// Create 5 sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createSession(user.id);
|
||||
}
|
||||
|
||||
// 6th should fail
|
||||
const token = generateToken(user.id);
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/session limit/i);
|
||||
});
|
||||
|
||||
it('MCP — session resumption with valid mcp-session-id', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const sessionId = await createSession(user.id);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('mcp-session-id', sessionId)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'tools/list', id: 2, params: {} });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('MCP — session belongs to different user returns 403', async () => {
|
||||
const { user: user1 } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
|
||||
const sessionId = await createSession(user1.id);
|
||||
const token2 = generateToken(user2.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token2}`)
|
||||
.set('mcp-session-id', sessionId)
|
||||
.send({ jsonrpc: '2.0', method: 'tools/list', id: 2 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('MCP — GET without mcp-session-id returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const token = generateToken(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP rate limiting', () => {
|
||||
it('MCP-005 — requests below limit succeed', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run();
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Set a very low rate limit via env for this test
|
||||
const originalLimit = process.env.MCP_RATE_LIMIT;
|
||||
process.env.MCP_RATE_LIMIT = '3';
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: i + 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } });
|
||||
// Each should pass (no rate limit hit yet since limit is read at module init,
|
||||
// but we can verify that the responses are not 429)
|
||||
expect(res.status).not.toBe(429);
|
||||
}
|
||||
} finally {
|
||||
if (originalLimit === undefined) delete process.env.MCP_RATE_LIMIT;
|
||||
else process.env.MCP_RATE_LIMIT = originalLimit;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
489
server/tests/unit/mcp/resources.test.ts
Normal file
489
server/tests/unit/mcp/resources.test.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Unit tests for MCP resources (resources.ts).
|
||||
* Tests all 14 resources via InMemoryTransport + Client.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } 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: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
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: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createBucketListItem, createVisitedCountry, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (harness: McpHarness) => Promise<void>) {
|
||||
const harness = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
try {
|
||||
await fn(harness);
|
||||
} finally {
|
||||
await harness.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe('Resource: trek://trips', () => {
|
||||
it('returns all trips the user owns or is a member of', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'My Trip' });
|
||||
const sharedTrip = createTrip(testDb, other.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, sharedTrip.id, user.id);
|
||||
// Trip from another user (not accessible)
|
||||
createTrip(testDb, other.id, { title: 'Other Trip' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://trips' });
|
||||
const trips = parseResourceResult(result) as any[];
|
||||
expect(trips).toHaveLength(2);
|
||||
const titles = trips.map((t) => t.title);
|
||||
expect(titles).toContain('My Trip');
|
||||
expect(titles).toContain('Shared Trip');
|
||||
expect(titles).not.toContain('Other Trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes archived trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Active Trip' });
|
||||
const archived = createTrip(testDb, user.id, { title: 'Archived Trip' });
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://trips' });
|
||||
const trips = parseResourceResult(result) as any[];
|
||||
expect(trips).toHaveLength(1);
|
||||
expect(trips[0].title).toBe('Active Trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when user has no trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://trips' });
|
||||
const trips = parseResourceResult(result) as any[];
|
||||
expect(trips).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}', () => {
|
||||
it('returns trip data for an accessible trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.title).toBe('Paris Trip');
|
||||
expect(data.id).toBe(trip.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for inaccessible trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const otherTrip = createTrip(testDb, other.id, { title: 'Private' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${otherTrip.id}` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-existent ID', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://trips/99999' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/days', () => {
|
||||
it('returns days with assignments in order', 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);
|
||||
createDayAssignment(testDb, day1.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days` });
|
||||
const days = parseResourceResult(result) as any[];
|
||||
expect(days).toHaveLength(2);
|
||||
expect(days[0].day_number).toBe(1);
|
||||
expect(days[0].assignments).toHaveLength(1);
|
||||
expect(days[1].day_number).toBe(2);
|
||||
expect(days[1].assignments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/places', () => {
|
||||
it('returns all places for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` });
|
||||
const places = parseResourceResult(result) as any[];
|
||||
expect(places).toHaveLength(2);
|
||||
const names = places.map((p) => p.name);
|
||||
expect(names).toContain('Eiffel Tower');
|
||||
expect(names).toContain('Louvre');
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget', () => {
|
||||
it('returns budget items for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/packing', () => {
|
||||
it('returns packing items for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Passport' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Passport');
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/packing` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/days/{dayId}/notes', () => {
|
||||
it('returns notes for a specific day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
createDayNote(testDb, day.id, trip.id, { text: 'Check in at noon' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/${day.id}/notes` });
|
||||
const notes = parseResourceResult(result) as any[];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].text).toBe('Check in at noon');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/${day.id}/notes` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for invalid dayId', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/abc/notes` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/accommodations', () => {
|
||||
it('returns accommodations for a trip', 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, { name: 'Grand Hotel' });
|
||||
createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].place_name).toBe('Grand Hotel');
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/members', () => {
|
||||
it('returns owner and collaborators', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.owner).toBeTruthy();
|
||||
expect(data.owner.id).toBe(user.id);
|
||||
expect(data.members).toHaveLength(1);
|
||||
expect(data.members[0].id).toBe(member.id);
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab-notes', () => {
|
||||
it('returns collab notes with username', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const notes = parseResourceResult(result) as any[];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].title).toBe('Ideas');
|
||||
expect(notes[0].username).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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 (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://categories', () => {
|
||||
it('returns all categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://categories' });
|
||||
const categories = parseResourceResult(result) as any[];
|
||||
expect(categories.length).toBeGreaterThan(0);
|
||||
expect(categories[0]).toHaveProperty('id');
|
||||
expect(categories[0]).toHaveProperty('name');
|
||||
expect(categories[0]).toHaveProperty('color');
|
||||
expect(categories[0]).toHaveProperty('icon');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://bucket-list', () => {
|
||||
it('returns only the current user\'s bucket list items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
createBucketListItem(testDb, user.id, { name: 'Tokyo' });
|
||||
createBucketListItem(testDb, other.id, { name: 'Rome' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://bucket-list' });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].name).toBe('Tokyo');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array for user with no items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://bucket-list' });
|
||||
const items = parseResourceResult(result) as any[];
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://visited-countries', () => {
|
||||
it('returns only the current user\'s visited countries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
createVisitedCountry(testDb, user.id, 'FR');
|
||||
createVisitedCountry(testDb, user.id, 'JP');
|
||||
createVisitedCountry(testDb, other.id, 'DE');
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://visited-countries' });
|
||||
const countries = parseResourceResult(result) as any[];
|
||||
expect(countries).toHaveLength(2);
|
||||
const codes = countries.map((c) => c.country_code);
|
||||
expect(codes).toContain('FR');
|
||||
expect(codes).toContain('JP');
|
||||
expect(codes).not.toContain('DE');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array for user with no visited countries', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: 'trek://visited-countries' });
|
||||
const countries = parseResourceResult(result) as any[];
|
||||
expect(countries).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
358
server/tests/unit/mcp/tools-assignments.test.ts
Normal file
358
server/tests/unit/mcp/tools-assignments.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Unit tests for MCP assignment tools: assign_place_to_day, unassign_place,
|
||||
* reorder_day_assignments, update_assignment_time.
|
||||
*/
|
||||
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, createDayAssignment } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assign_place_to_day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: assign_place_to_day', () => {
|
||||
it('assigns a place to a day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'assign_place_to_day',
|
||||
arguments: { tripId: trip.id, dayId: day.id, placeId: place.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment).toBeTruthy();
|
||||
expect(data.assignment.day_id).toBe(day.id);
|
||||
expect(data.assignment.place_id).toBe(place.id);
|
||||
expect(data.assignment.order_index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-increments order_index for subsequent assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'P1' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'P2' });
|
||||
createDayAssignment(testDb, day.id, place1.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'assign_place_to_day',
|
||||
arguments: { tripId: trip.id, dayId: day.id, placeId: place2.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment.order_index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts assignment:created event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when day does not belong 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);
|
||||
const place = createPlace(testDb, trip1.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'assign_place_to_day',
|
||||
arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, placeId: place.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when place does not belong to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip1 = createTrip(testDb, user.id);
|
||||
const trip2 = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip1.id);
|
||||
const placeFromTrip2 = createPlace(testDb, trip2.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'assign_place_to_day',
|
||||
arguments: { tripId: trip1.id, dayId: day.id, placeId: 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 day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unassign_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: unassign_place', () => {
|
||||
it('removes a place assignment from a day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unassign_place',
|
||||
arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(assignment.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts assignment:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when assignment is not found', 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: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: 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 day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_day_assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_day_assignments', () => {
|
||||
it('reorders assignments by updating order_index', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'First' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Second' });
|
||||
const a1 = createDayAssignment(testDb, day.id, place1.id, { order_index: 0 });
|
||||
const a2 = createDayAssignment(testDb, day.id, place2.id, { order_index: 1 });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_day_assignments',
|
||||
arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a2.id, a1.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
|
||||
const a1Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a1.id) as { order_index: number };
|
||||
const a2Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a2.id) as { order_index: number };
|
||||
expect(a2Updated.order_index).toBe(0);
|
||||
expect(a1Updated.order_index).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts assignment:reordered event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a.id] } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:reordered', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when day does not belong to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip1 = createTrip(testDb, user.id);
|
||||
const trip2 = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip2.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip1.id, dayId: day.id, assignmentIds: [1] } });
|
||||
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 day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [1] } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_assignment_time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_assignment_time', () => {
|
||||
it('sets start and end times for an assignment', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_assignment_time',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00', end_time: '11:30' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment.assignment_time).toBe('09:00');
|
||||
expect(data.assignment.assignment_end_time).toBe('11:30');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears times with null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('09:00', '11:00', assignment.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_assignment_time',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: null, end_time: null },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment.assignment_time).toBeNull();
|
||||
expect(data.assignment.assignment_end_time).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts assignment:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '10:00' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when assignment 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_assignment_time', arguments: { tripId: trip.id, assignmentId: 99999, place_time: '09:00' } });
|
||||
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 day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
server/tests/unit/mcp/tools-atlas.test.ts
Normal file
218
server/tests/unit/mcp/tools-atlas.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Unit tests for MCP atlas and bucket list tools:
|
||||
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item.
|
||||
*/
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_country_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_country_visited', () => {
|
||||
it('marks a country as visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'FR' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.country_code).toBe('FR');
|
||||
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'FR');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent — marking twice does not error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createVisitedCountry(testDb, user.id, 'JP');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'JP' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const count = (testDb.prepare('SELECT COUNT(*) as c FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'JP') as { c: number }).c;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'DE' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unmark_country_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: unmark_country_visited', () => {
|
||||
it('removes a visited country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createVisitedCountry(testDb, user.id, 'ES');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'ES');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds even when country was not marked (no-op)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'AU' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
createVisitedCountry(testDb, user.id, 'IT');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'IT' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_bucket_list_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_bucket_list_item', () => {
|
||||
it('creates a bucket list item with all fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_bucket_list_item',
|
||||
arguments: { name: 'Kyoto', lat: 35.0116, lng: 135.7681, country_code: 'JP', notes: 'Cherry blossom season' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Kyoto');
|
||||
expect(data.item.country_code).toBe('JP');
|
||||
expect(data.item.notes).toBe('Cherry blossom season');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a minimal item (name only)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_bucket_list_item', arguments: { name: 'Antarctica' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Antarctica');
|
||||
expect(data.item.user_id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_bucket_list_item', arguments: { name: 'Nowhere' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_bucket_list_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_bucket_list_item', () => {
|
||||
it('deletes a bucket list item owned by the user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const item = createBucketListItem(testDb, user.id, { name: 'Machu Picchu' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE id = ?').get(item.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item not found (wrong user)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const item = createBucketListItem(testDb, other.id, { name: "Other's Wish" });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: 99999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const item = createBucketListItem(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
server/tests/unit/mcp/tools-budget.test.ts
Normal file
223
server/tests/unit/mcp/tools-budget.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Unit tests for MCP budget tools: create_budget_item, update_budget_item, delete_budget_item.
|
||||
*/
|
||||
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, createBudgetItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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_budget_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_budget_item', () => {
|
||||
it('creates a budget item with all fields', 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_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'Hotel Paris', category: 'Accommodation', total_price: 500, note: 'Prepaid' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Hotel Paris');
|
||||
expect(data.item.category).toBe('Accommodation');
|
||||
expect(data.item.total_price).toBe(500);
|
||||
expect(data.item.note).toBe('Prepaid');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults category to "Other" when not specified', 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_budget_item',
|
||||
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.category).toBe('Other');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts budget: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_budget_item', arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget: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_budget_item', arguments: { tripId: trip.id, name: 'Hack', total_price: 0 } });
|
||||
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: 'create_budget_item', arguments: { tripId: trip.id, name: 'X', total_price: 0 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_budget_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_budget_item', () => {
|
||||
it('updates budget item fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Old', category: 'Food', total_price: 50 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_budget_item',
|
||||
arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', total_price: 75 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New Name');
|
||||
expect(data.item.total_price).toBe(75);
|
||||
expect(data.item.category).toBe('Food'); // preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts budget:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_budget_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
|
||||
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 item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_budget_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_budget_item', () => {
|
||||
it('deletes an existing budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts budget:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_budget_item', arguments: { tripId: trip.id, itemId: 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 item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
server/tests/unit/mcp/tools-days.test.ts
Normal file
142
server/tests/unit/mcp/tools-days.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Unit tests for MCP day tools: update_day.
|
||||
*/
|
||||
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 } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_day', () => {
|
||||
it('sets a day title', 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, title: 'Arrival in Paris' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day.title).toBe('Arrival in Paris');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a day title with null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { title: 'Old Title' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_day',
|
||||
arguments: { tripId: trip.id, dayId: day.id, title: null },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day.title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts day:updated event', 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: 'Day 1' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when day does not belong 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: 'update_day',
|
||||
arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, title: 'X' },
|
||||
});
|
||||
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 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, title: 'X' } });
|
||||
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);
|
||||
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, title: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
431
server/tests/unit/mcp/tools-notes.test.ts
Normal file
431
server/tests/unit/mcp/tools-notes.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note,
|
||||
* create_collab_note, update_collab_note, delete_collab_note.
|
||||
*/
|
||||
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, unlinkSyncMock } = vi.hoisted(() => ({
|
||||
broadcastMock: vi.fn(),
|
||||
unlinkSyncMock: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return { ...actual, unlinkSync: unlinkSyncMock };
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
unlinkSyncMock.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_day_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_day_note', () => {
|
||||
it('creates a note on a day', 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: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'Check in at noon', time: '12:00', icon: '🏨' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.text).toBe('Check in at noon');
|
||||
expect(data.note.time).toBe('12:00');
|
||||
expect(data.note.icon).toBe('🏨');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults icon to 📝', 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: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'A note' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.icon).toBe('📝');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts dayNote:created event', 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: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'Note' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when day does not belong 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_day_note', arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, text: 'Note' } });
|
||||
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 day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_day_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_day_note', () => {
|
||||
it('updates note text, time, icon', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text', time: '09:00', icon: '📝' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'New text', time: '14:00', icon: '🍽️' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.text).toBe('New text');
|
||||
expect(data.note.time).toBe('14:00');
|
||||
expect(data.note.icon).toBe('🍽️');
|
||||
});
|
||||
});
|
||||
|
||||
it('trims text whitespace', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: ' Trimmed ' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.text).toBe('Trimmed');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts dayNote:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when note not found', 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_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999, text: 'X' } });
|
||||
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 day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_day_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_day_note', () => {
|
||||
it('deletes a day note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts dayNote:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when note not found', 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: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 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 day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_collab_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_collab_note', () => {
|
||||
it('creates a collab note with all fields', 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_collab_note',
|
||||
arguments: { tripId: trip.id, title: 'Ideas', content: 'Visit museums', category: 'Culture', color: '#3b82f6' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.title).toBe('Ideas');
|
||||
expect(data.note.content).toBe('Visit museums');
|
||||
expect(data.note.category).toBe('Culture');
|
||||
expect(data.note.color).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults category to "General" and color to "#6366f1"', 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_collab_note', arguments: { tripId: trip.id, title: 'Quick note' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.category).toBe('General');
|
||||
expect(data.note.color).toBe('#6366f1');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts collab:note: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_collab_note', arguments: { tripId: trip.id, title: 'Note' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note: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_collab_note', arguments: { tripId: trip.id, title: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_collab_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_collab_note', () => {
|
||||
it('updates collab note fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id, { title: 'Old', color: '#6366f1' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_collab_note',
|
||||
arguments: { tripId: trip.id, noteId: note.id, title: 'New Title', pinned: true, color: '#3b82f6' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.note.title).toBe('New Title');
|
||||
expect(data.note.pinned).toBe(1);
|
||||
expect(data.note.color).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts collab:note:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when note 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_collab_note', arguments: { tripId: trip.id, noteId: 99999, title: 'X' } });
|
||||
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 note = createCollabNote(testDb, trip.id, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_collab_note
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_collab_note', () => {
|
||||
it('deletes a collab note', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes associated trip_files records from the database', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
// Insert a trip_file linked to this note
|
||||
testDb.prepare(
|
||||
`INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
||||
expect((parseToolResult(result) as any).success).toBe(true);
|
||||
});
|
||||
|
||||
// trip_files rows are deleted as part of the transaction
|
||||
expect(testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').all(note.id)).toHaveLength(0);
|
||||
// note itself is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('broadcasts collab:note:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const note = createCollabNote(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when note 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_collab_note', arguments: { tripId: trip.id, noteId: 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 note = createCollabNote(testDb, trip.id, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
server/tests/unit/mcp/tools-packing.test.ts
Normal file
287
server/tests/unit/mcp/tools-packing.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Unit tests for MCP packing tools: create_packing_item, update_packing_item,
|
||||
* toggle_packing_item, delete_packing_item.
|
||||
*/
|
||||
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, createPackingItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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_packing_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_packing_item', () => {
|
||||
it('creates a packing item', 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_packing_item',
|
||||
arguments: { tripId: trip.id, name: 'Passport', category: 'Documents' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Passport');
|
||||
expect(data.item.category).toBe('Documents');
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults category to "General"', 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_packing_item',
|
||||
arguments: { tripId: trip.id, name: 'Sunscreen' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.category).toBe('General');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts packing: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_packing_item', arguments: { tripId: trip.id, name: 'Hat' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing: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_packing_item', arguments: { tripId: trip.id, name: 'X' } });
|
||||
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: 'create_packing_item', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_packing_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_packing_item', () => {
|
||||
it('updates packing item name and category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id, { name: 'Old', category: 'Clothes' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_packing_item',
|
||||
arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', category: 'Electronics' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New Name');
|
||||
expect(data.item.category).toBe('Electronics');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts packing:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_packing_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
|
||||
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 item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_packing_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_packing_item', () => {
|
||||
it('checks a packing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_packing_item',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('unchecks a packing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
testDb.prepare('UPDATE packing_items SET checked = 1 WHERE id = ?').run(item.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_packing_item',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts packing:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
|
||||
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 item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_packing_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_packing_item', () => {
|
||||
it('deletes an existing packing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM packing_items WHERE id = ?').get(item.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts packing:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item 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_packing_item', arguments: { tripId: trip.id, itemId: 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 item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
310
server/tests/unit/mcp/tools-places.test.ts
Normal file
310
server/tests/unit/mcp/tools-places.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Unit tests for MCP place tools: create_place, update_place, delete_place, list_categories, search_place.
|
||||
*/
|
||||
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: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
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, createPlace } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_place', () => {
|
||||
it('creates a place with all fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_place',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
name: 'Eiffel Tower',
|
||||
lat: 48.8584,
|
||||
lng: 2.2945,
|
||||
address: 'Champ de Mars, Paris',
|
||||
category_id: cat.id,
|
||||
notes: 'Must visit',
|
||||
website: 'https://toureiffel.paris',
|
||||
phone: '+33 892 70 12 39',
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.place.name).toBe('Eiffel Tower');
|
||||
expect(data.place.lat).toBeCloseTo(48.8584);
|
||||
expect(data.place.category_id).toBe(cat.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a place with minimal fields', 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_place',
|
||||
arguments: { tripId: trip.id, name: 'Mystery Spot' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.place.name).toBe('Mystery Spot');
|
||||
expect(data.place.trip_id).toBe(trip.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts place: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_place', arguments: { tripId: trip.id, name: 'Cafe' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place: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_place', arguments: { tripId: trip.id, name: 'Hack' } });
|
||||
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: 'create_place', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_place', () => {
|
||||
it('updates specific fields and preserves others', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_place',
|
||||
arguments: { tripId: trip.id, placeId: place.id, name: 'New Name' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.place.name).toBe('New Name');
|
||||
// lat/lng preserved from original
|
||||
expect(data.place.lat).toBeCloseTo(place.lat ?? 48.8566);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts place:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for place not found in trip', 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_place', arguments: { tripId: trip.id, placeId: 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 place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_place', () => {
|
||||
it('deletes an existing place', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(place.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts place:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for place 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_place', arguments: { tripId: trip.id, placeId: 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 place = createPlace(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_categories', () => {
|
||||
it('returns all categories with id, name, color, icon', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_categories', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.categories).toBeDefined();
|
||||
expect(data.categories.length).toBeGreaterThan(0);
|
||||
const cat = data.categories[0];
|
||||
expect(cat).toHaveProperty('id');
|
||||
expect(cat).toHaveProperty('name');
|
||||
expect(cat).toHaveProperty('color');
|
||||
expect(cat).toHaveProperty('icon');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// search_place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: search_place', () => {
|
||||
it('returns formatted results from Nominatim', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
osm_type: 'node',
|
||||
osm_id: 12345,
|
||||
name: 'Eiffel Tower',
|
||||
display_name: 'Eiffel Tower, Paris, France',
|
||||
lat: '48.8584',
|
||||
lon: '2.2945',
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].name).toBe('Eiffel Tower');
|
||||
expect(data.places[0].osm_id).toBe('node:12345');
|
||||
expect(data.places[0].lat).toBeCloseTo(48.8584);
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns error when Nominatim API fails', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'something' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
434
server/tests/unit/mcp/tools-reservations.test.ts
Normal file
434
server/tests/unit/mcp/tools-reservations.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 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, createDayAssignment } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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 flight 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: 'Flight to Rome', type: 'flight' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.title).toBe('Flight to Rome');
|
||||
expect(data.reservation.type).toBe('flight');
|
||||
expect(data.reservation.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
340
server/tests/unit/mcp/tools-trips.test.ts
Normal file
340
server/tests/unit/mcp/tools-trips.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Unit tests for MCP trip tools: create_trip, update_trip, delete_trip, list_trips, get_trip_summary.
|
||||
*/
|
||||
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, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, 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_trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_trip', () => {
|
||||
it('creates a trip with title only and generates 7 default days', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Summer Escape' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip).toBeTruthy();
|
||||
expect(data.trip.title).toBe('Summer Escape');
|
||||
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
|
||||
expect(days.c).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a trip with dates and auto-generates correct number of days', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_trip',
|
||||
arguments: { title: 'Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
|
||||
expect(days.c).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
it('caps days at 90 for very long trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_trip',
|
||||
arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
|
||||
expect(days.c).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for invalid start_date format', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Trip', start_date: 'not-a-date' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when end_date is before start_date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_trip',
|
||||
arguments: { title: 'Trip', start_date: '2026-07-05', end_date: '2026-07-01' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Demo Trip' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_trip', () => {
|
||||
it('updates trip title', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Old Title' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'New Title' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip.title).toBe('New Title');
|
||||
});
|
||||
});
|
||||
|
||||
it('partial update preserves unspecified fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A great trip' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Renamed' } });
|
||||
const updated = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(trip.id) as any;
|
||||
expect(updated.title).toBe('Renamed');
|
||||
expect(updated.description).toBe('A great trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts trip:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip:updated', 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: 'update_trip', arguments: { tripId: trip.id, title: 'Hack' } });
|
||||
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: 'update_trip', arguments: { tripId: trip.id, title: 'New' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_trip', () => {
|
||||
it('owner can delete trip', 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_trip', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const gone = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
|
||||
expect(gone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('non-owner member cannot delete trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
const stillExists = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id);
|
||||
expect(stillExists).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
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: 'delete_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_trips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_trips', () => {
|
||||
it('returns owned and member trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'My Trip' });
|
||||
const shared = createTrip(testDb, other.id, { title: 'Shared' });
|
||||
addTripMember(testDb, shared.id, user.id);
|
||||
createTrip(testDb, other.id, { title: 'Inaccessible' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trips', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trips).toHaveLength(2);
|
||||
const titles = data.trips.map((t: any) => t.title);
|
||||
expect(titles).toContain('My Trip');
|
||||
expect(titles).toContain('Shared');
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes archived trips by default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Active' });
|
||||
const archived = createTrip(testDb, user.id, { title: 'Archived' });
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trips', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trips).toHaveLength(1);
|
||||
expect(data.trips[0].title).toBe('Active');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes archived trips when include_archived is true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Active' });
|
||||
const archived = createTrip(testDb, user.id, { title: 'Archived' });
|
||||
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trips', arguments: { include_archived: true } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trips).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_trip_summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_trip_summary', () => {
|
||||
it('returns full denormalized trip snapshot', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Full Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
createDayNote(testDb, day.id, trip.id, { text: 'Check in' });
|
||||
createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 300 });
|
||||
createPackingItem(testDb, trip.id, { name: 'Passport' });
|
||||
createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Plan' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip.title).toBe('Full Trip');
|
||||
expect(data.members.owner.id).toBe(user.id);
|
||||
expect(data.members.collaborators).toHaveLength(1);
|
||||
expect(data.days).toHaveLength(1);
|
||||
expect(data.days[0].assignments).toHaveLength(1);
|
||||
expect(data.days[0].notes).toHaveLength(1);
|
||||
expect(data.budget.item_count).toBe(1);
|
||||
expect(data.budget.total).toBe(300);
|
||||
expect(data.packing.total).toBe(1);
|
||||
expect(data.reservations).toHaveLength(1);
|
||||
expect(data.collab_notes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'get_trip_summary', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('is not blocked for demo user (read-only tool)', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id, { title: 'Demo Trip' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip.title).toBe('Demo Trip');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,14 @@ export default defineConfig({
|
||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js',
|
||||
import.meta.url
|
||||
).pathname,
|
||||
'@modelcontextprotocol/sdk/inMemory': new URL(
|
||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js',
|
||||
import.meta.url
|
||||
).pathname,
|
||||
'@modelcontextprotocol/sdk/client/index': new URL(
|
||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js',
|
||||
import.meta.url
|
||||
).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user