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:
Julien G.
2026-04-04 18:23:35 +02:00
committed by GitHub
24 changed files with 3959 additions and 613 deletions

View File

@@ -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;
}

View File

@@ -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);
}
);

View File

@@ -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 });
}

View File

@@ -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 || '{}') }));

View File

@@ -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: {

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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 }> = {}

View 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);
}

View File

@@ -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;
}
});
});

View 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([]);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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();
});
});

View 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);
});
});
});

View 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');
});
});
});

View File

@@ -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,
},
},
});