chore: move trip backend to proper DI

Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
This commit is contained in:
jubnl
2026-07-29 01:53:40 +02:00
parent a3c2cfa4b2
commit 530c7cb005
34 changed files with 3576 additions and 1804 deletions
+7
View File
@@ -12,6 +12,13 @@ declare module '@trek/nest-mcp' {
userId: number;
scopes: string[] | null;
isStaticToken: boolean;
/**
* Fire-once static-token deprecation notice closure (built per session in
* src/mcp/index.ts and threaded through registerTools → registry.attach).
* Optional so direct createTestRegistry ctxs without it keep working —
* consumers (list_trips / get_trip_summary) treat absence as "no notice".
*/
getDeprecationNotice?: () => string | null;
}
// The augmentation pattern requires an empty single-extends interface —
// same false positive (and same disable) as McpContext in @trek/nest-mcp.
+6 -39
View File
@@ -1,13 +1,12 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { canAccessTrip } from '../db/database';
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
import { listPlaces } from '../services/placeService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { isAddonEnabled } from '../nest/addons/addons.bridge';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
import { canRead, canReadTrips } from './scopes';
import { canRead } from './scopes';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -45,29 +44,9 @@ function jsonContent(uri: string, data: unknown) {
}
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
// List all accessible trips
if (canReadTrips(scopes)) server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
async (uri) => {
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
}
);
// Single trip detail
if (canReadTrips(scopes)) server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = getTrip(id, userId);
return jsonContent(uri.href, trip);
}
);
// The trips and trip (trek://trips, trek://trips/{tripId}) resources moved
// to the DI-discovered src/nest/trips/trips.mcp.ts (@Resource /
// @ResourceTemplate, attached via the nest-mcp registry in registerTools).
// The trip-days resource moved to the DI-discovered
// src/nest/days/days.mcp.ts (@ResourceTemplate).
@@ -101,20 +80,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
// The trip-accommodations resource moved to the DI-discovered
// src/nest/days/days.mcp.ts (@ResourceTemplate).
// Trip members (owner + collaborators)
if (canReadTrips(scopes)) server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const ownerRow = getTripOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
);
// The trip-members resource moved to the DI-discovered
// src/nest/trips/trips.mcp.ts (@ResourceTemplate).
// The trek://trips/{tripId}/collab-notes, …/collab/polls and …/collab/messages
// resources moved to the DI-discovered src/nest/collab/collab.mcp.ts
+5 -3
View File
@@ -5,14 +5,16 @@ import { registerNotificationTools } from './tools/notifications';
import { registerAtlasTools } from './tools/atlas';
import { registerPlaceTools } from './tools/places';
import { registerCollectionTools } from './tools/collections';
import { registerTripTools } from './tools/trips';
import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerMcpPrompts } from './tools/prompts';
import { getMcpRegistry } from './registry-handoff';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
registerTripTools(server, userId, scopes, getDeprecationNotice);
// The trip tools moved to the DI-discovered src/nest/trips/trips.mcp.ts and
// the share-link tools to src/nest/share/share.mcp.ts (@McpController,
// attached via the nest-mcp registry below — getDeprecationNotice rides the
// attach ctx).
registerPlaceTools(server, userId, scopes);
@@ -61,5 +63,5 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
// callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness
// attaches its own via createTestRegistry + setMcpRegistry.
const registry = getMcpRegistry();
if (registry) registry.attach(server, { userId, scopes, isStaticToken });
if (registry) registry.attach(server, { userId, scopes, isStaticToken, getDeprecationNotice });
}
+4 -35
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { getTripSummary } from '../../nest/trips/trips.bridge';
import { listItems as listPackingItems } from '../../nest/packing/packing.bridge';
import { isAddonEnabled } from '../../nest/addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
@@ -29,40 +29,9 @@ export function registerMcpPrompts(server: McpServer, _userId: number, isStaticT
}
const userId = _userId;
server.registerPrompt(
'trip-summary',
{
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId, userId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collab_notes } = summary;
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collab_notes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
);
// The trip-summary prompt moved to the DI-discovered
// src/nest/trips/trips.mcp.ts (@Prompt, attached via the nest-mcp registry
// in registerTools).
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt(
'packing-list',
-396
View File
@@ -1,396 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
isOwner, verifyTripAccess,
listMembers as listTripMembers, getTripOwner, addMember as addTripMember,
removeMember as removeTripMember,
copyTripById, exportICS, NotFoundError, ValidationError,
} from '../../services/tripService';
import {
createOrUpdateShareLink, getShareLink, deleteShareLink,
} from '../../nest/share/share.bridge';
import { isAddonEnabled, getCollabFeatures } from '../../nest/addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../nest/collab/collab.bridge';
import {
listItems as listTodoItems,
} from '../../nest/todo/todo.bridge';
import { rebaseTripCurrency } from '../../nest/budget/budget.bridge';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
} from './_shared';
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
const R = canReadTrips(scopes);
const W = canWrite(scopes, 'trips');
const D = canDeleteTrips(scopes);
const S = canShareTrips(scopes);
// --- TRIPS ---
if (W) server.registerTool(
'create_trip',
{
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
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 };
}
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 } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
);
if (W) server.registerTool(
'update_trip',
{
description: 'Update an existing trip\'s details.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
date_shift_mode: z.enum(['keep_bookings', 'shift_all']).optional().describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
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 };
}
// Re-anchor the budget before the trip row moves off the old currency (#1543).
await rebaseTripCurrency(tripId, currency);
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
);
if (D) server.registerTool(
'delete_trip',
{
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!isOwner(tripId, userId)) return noAccess();
deleteTrip(tripId, userId, 'user');
return ok({ success: true, tripId });
}
);
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
server.registerTool(
'list_trips',
{
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ include_archived }) => {
const notice = getDeprecationNotice();
const trips = listTrips(userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
);
// --- TRIP SUMMARY ---
server.registerTool(
'get_trip_summary',
{
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const summary = getTripSummary(tripId, userId);
if (!summary) return noAccess();
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
const canReadRes = canRead(scopes, 'reservations');
const todos = canReadTodos ? listTodoItems(tripId) : [];
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = countMessages(tripId);
}
const notice = getDeprecationNotice();
// The core bucket (trip metadata, members WITH email, days with place
// coordinates, accommodations) carries confidential PII and itinerary data,
// so it is gated on trips:read just like the sub-sections below. Without a
// read scope the tool still resolves trip id + title so it stays usable for
// navigation (list_trips already covers discovery). trek_ PATs (null scopes)
// and any trips:read holder keep the full payload — no behaviour change.
const summaryData = {
trip: R ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
members: R ? summary.members : undefined,
days: R ? summary.days : undefined,
// Accommodations are "accommodation details" under reservations:read too
// (see SCOPE_INFO) and pair with reservations in the share payload, so a
// reservations-scoped token keeps them — gate on either read scope.
accommodations: (R || canReadRes) ? summary.accommodations : undefined,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok(summaryData);
}
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
if (R) server.registerTool(
'list_trip_members',
{
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = listTripMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
);
if (W) server.registerTool(
'add_trip_member',
{
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, identifier }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = addTripMember(tripId, identifier, ownerRow.user_id, userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
}
);
if (W) server.registerTool(
'remove_trip_member',
{
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, memberId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const ownerRow = getTripOwner(tripId);
if (!ownerRow || ownerRow.user_id !== userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
removeTripMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
);
if (W) server.registerTool(
'copy_trip',
{
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const newTripId = copyTripById(tripId, userId, title);
const newTrip = canAccessTrip(newTripId, userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
);
if (R) server.registerTool(
'export_trip_ics',
{
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
try {
const { ics, filename } = exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
}
);
if (S) server.registerTool(
'get_share_link',
{
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
// only requires trip membership (share_manage gates create/delete, not read).
if (!canAccessTrip(tripId, userId)) return noAccess();
const link = getShareLink(String(tripId));
return ok({ link });
}
);
if (S) server.registerTool(
'create_share_link',
{
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
}
);
if (S) server.registerTool(
'delete_share_link',
{
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied();
deleteShareLink(String(tripId));
return ok({ success: true });
}
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { isDemoUser } from '../../services/authService';
import { isAddonEnabled } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { getTripOwner, listMembers } from '../../services/tripService';
import { getTripOwner, listMembers } from '../trips/trips.bridge';
import { DatabaseService } from '../database/database.service';
import { BudgetService } from './budget.service';
import { ExchangeRatesService } from './exchange-rates.service';
@@ -109,13 +109,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'SynologyMemoriesController.test',
'TagsController.create',
'TagsController.update',
'TripsController.addMember',
'TripsController.copy',
'TripsController.create',
'TripsController.createGuest',
'TripsController.renameGuest',
'TripsController.transferOwnership',
'TripsController.update',
'UnifiedMemoriesController.addPhotos',
'UnifiedMemoriesController.createAlbumLink',
'UnifiedMemoriesController.setSharing',
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { TripsModule } from '../trips/trips.module';
import { FeedsService } from './feeds.service';
import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller';
@Module({
imports: [TripsModule],
controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController],
providers: [FeedsService],
})
+7 -4
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DatabaseService } from '../database/database.service';
import { exportICS } from '../../services/tripService';
import { TripsService } from '../trips/trips.service';
const ninetyDaysAgo = () => {
const d = new Date();
@@ -15,7 +15,10 @@ function feedUrl(token: string, scope: 'trip' | 'user', base: string): string {
@Injectable()
export class FeedsService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
private readonly dbs: DatabaseService,
private readonly trips: TripsService,
) {}
private get db() {
return this.dbs.connection;
@@ -92,7 +95,7 @@ export class FeedsService {
| undefined;
if (!row) return null;
try {
const { ics, filename } = exportICS(row.id);
const { ics, filename } = this.trips.exportICS(row.id);
// Inject calendar-subscription refresh hints into the VCALENDAR header so
// clients re-fetch hourly. The one-time download path (exportICS) is left
// untouched; this is feed-only.
@@ -142,7 +145,7 @@ export class FeedsService {
let events = '';
for (const { id } of trips) {
try {
const { ics } = exportICS(id);
const { ics } = this.trips.exportICS(id);
for (const vtz of extractVTimezones(ics)) {
const tzid = vtz.match(/\r\nTZID:(.+)\r\n/)?.[1];
if (tzid && !zones.has(tzid)) zones.set(tzid, vtz);
+8 -7
View File
@@ -7,13 +7,14 @@ import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the packing domain — for code running OUTSIDE the
* Nest container (the legacy packing-list prompt registrar in
* src/mcp/tools/prompts.ts and the trip-summary packing counts in
* services/tripService.ts; the packing MCP tools and resources moved to the
* DI-discovered packing.mcp.ts, and the plugin RPC host injects PackingService
* via PluginHostDepsFactory). Exports only the legacy services/packingService
* names still consumed outside the container, 1:1, so repointing a consumer is
* an import-path-only diff. Inside the container, inject PackingService
* instead. Delete this file when those legacy consumers migrate.
* src/mcp/tools/prompts.ts; the packing MCP tools and resources moved to the
* DI-discovered packing.mcp.ts, the plugin RPC host injects PackingService
* via PluginHostDepsFactory, and the trip domain folded into the DI-native
* TripsService, which injects PackingService). Exports only the legacy
* services/packingService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject PackingService instead. Delete this file when the prompts registrar
* migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
-33
View File
@@ -1,33 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { ShareService } from './share.service';
import { PermissionsService } from '../permissions/permissions.service';
import type { SharePermissions } from './share.service';
import { SettingsService } from '../settings/settings.service';
/**
* Non-Nest entry point for the share domain — for code running OUTSIDE the
* Nest container (the legacy share-link tools in src/mcp/tools/trips.ts; those
* stay in the trips registrar because their `trips:share` scope gate has no
* declarative `access: { group, mode }` equivalent yet). Exports only the
* legacy services/shareService names still consumed outside the container,
* 1:1, so repointing a consumer is an import-path-only diff. Inside the
* container, inject ShareService instead.
* Delete this file when the legacy MCP trips registrar migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const share = new ShareService(new DatabaseService(db), new SettingsService(new DatabaseService(db)), new PermissionsService(new DatabaseService(db)));
export function createOrUpdateShareLink(tripId: string, createdBy: number, permissions: SharePermissions) {
return share.createOrUpdate(tripId, createdBy, permissions);
}
export function getShareLink(tripId: string) {
return share.get(tripId);
}
export function deleteShareLink(tripId: string) {
return share.remove(tripId);
}
+88
View File
@@ -0,0 +1,88 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { canShareTrips } from '../../mcp/scopes';
import { ShareService } from './share.service';
/**
* Share-link MCP surface — ported 1:1 from the three share tools that lived in
* the legacy src/mcp/tools/trips.ts registrar (identical names, descriptions,
* schemas, annotations and error/payload shapes). They stayed behind when the
* share domain migrated because the `trips:share` scope gate has no
* declarative `access: { group, mode }` equivalent — all three use the
* predicate escape hatch instead, preserving the legacy `if (S)` gate exactly.
*/
@McpController()
export class ShareMcp {
constructor(private readonly share: ShareService) {}
@Tool({
name: 'get_share_link',
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canShareTrips(ctx.scopes),
})
async getShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
// Read parity with the REST route GET /api/trips/:tripId/share-link, which
// only requires trip membership (share_manage gates create/delete, not read).
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
const link = this.share.get(String(tripId));
return ok({ link });
}
@Tool({
name: 'create_share_link',
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
inputSchema: {
tripId: z.number().int().positive(),
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: (ctx) => canShareTrips(ctx.scopes),
})
async createShareLink(
{ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }: {
tripId: number; share_map?: boolean; share_bookings?: boolean; share_packing?: boolean; share_budget?: boolean; share_collab?: boolean;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied();
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
});
return ok({ token, created });
}
@Tool({
name: 'delete_share_link',
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: (ctx) => canShareTrips(ctx.scopes),
})
async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess();
if (!hasTripPermission('share_manage', tripId, ctx.userId)) return permissionDenied();
this.share.remove(String(tripId));
return ok({ success: true });
}
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { TripShareController, SharedController } from './share.controller';
import { ShareService } from './share.service';
import { ShareMcp } from './share.mcp';
import { SettingsModule } from '../settings/settings.module';
import { PermissionsModule } from '../permissions/permissions.module';
@Module({
imports: [SettingsModule, PermissionsModule],
controllers: [TripShareController, SharedController],
providers: [ShareService],
providers: [ShareService, ShareMcp],
})
export class ShareModule {}
-41
View File
@@ -1,41 +0,0 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TodoService } from './todo.service';
import { PermissionsService } from '../permissions/permissions.service';
/**
* Non-Nest entry point for the todo domain — for code running OUTSIDE the
* Nest container (the legacy get_trip_summary registrar in
* src/mcp/tools/trips.ts; the todo MCP tools and resource moved to the
* DI-discovered todo.mcp.ts, and the plugin RPC host injects TodoService via
* PluginHostDepsFactory). Exports only the legacy services/todoService names
* still consumed outside the container, 1:1, so repointing a consumer is an
* import-path-only diff. Inside the container, inject TodoService instead.
* Delete this file when the legacy MCP trips registrar migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const todos = new TodoService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService());
export function listItems(tripId: string | number) {
return todos.listItems(tripId);
}
export function createItem(tripId: string | number, data: Parameters<TodoService['createItem']>[1]) {
return todos.createItem(tripId, data);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: Parameters<TodoService['updateItem']>[2],
bodyKeys: string[]
) {
return todos.updateItem(tripId, id, data, bodyKeys);
}
export function deleteItem(tripId: string | number, id: string | number): boolean {
return todos.deleteItem(tripId, id);
}
+57
View File
@@ -0,0 +1,57 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { TripsService } from './trips.service';
import { TodoService } from '../todo/todo.service';
import { PackingService } from '../packing/packing.service';
import { FilesService } from '../files/files.service';
import { ReservationsService } from '../reservations/reservations.service';
import { DaysService } from '../days/days.service';
import { BudgetService } from '../budget/budget.service';
import { ExchangeRatesService } from '../budget/exchange-rates.service';
import { CollabService } from '../collab/collab.service';
import { VacayService } from '../vacay/vacay.service';
/**
* Non-Nest entry point for the trip domain — for the two consumers that cannot
* inject TripsService: the legacy packing-list/budget-overview prompt
* registrar in src/mcp/tools/prompts.ts (outside the container) and
* budget.mcp.ts's owner/member lookups (injecting TripsService there would
* need a forwardRef'd TripsModule↔BudgetModule cycle, so it stays on the
* bridge seam migration-graph.md planned). Exports only the legacy
* services/tripService names they consume, 1:1, so repointing was an
* import-path-only diff. Everything else injects TripsService. Delete this
* file when the prompts registrar migrates and the budget MCP surface can
* inject TripsService without a module cycle.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const dbs = () => new DatabaseService(db);
const budget = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const trips = new TripsService(
dbs(),
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budget, new RealtimeService()),
new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PermissionsService(dbs()),
budget,
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
);
export function getTripOwner(tripId: string | number) {
return trips.getOwner(tripId);
}
export function listMembers(tripId: string | number, tripOwnerId: number) {
return trips.listMembers(tripId, tripOwnerId);
}
export function getTripSummary(tripId: number, viewerUserId?: number) {
return trips.getTripSummary(tripId, viewerUserId);
}
+20 -25
View File
@@ -31,7 +31,8 @@ import { getClientIp } from '../audit/client-ip';
import { logInfo } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { isDemoEmail } from '../../services/demo';
import { NotFoundError, ValidationError } from '../../services/tripService';
import { NotFoundError, ValidationError } from './trips.service';
import { TripCreateDto, TripUpdateDto, TripCopyDto, TripAddMemberDto, TripTransferOwnershipDto, TripCreateGuestDto, TripRenameGuestDto } from './trips.dto';
import { saveUnsplashCover, isUnsplashCoverUrl } from '../../services/unsplashService';
const MAX_COVER_SIZE = 20 * 1024 * 1024;
@@ -92,16 +93,14 @@ export class TripsController {
@Post()
@HttpCode(201)
create(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Req() req: Request) {
create(@CurrentUser() user: User, @Body() body: TripCreateDto, @Req() req: Request) {
if (!this.trips.can('trip_create', user.role, null, user.id, false)) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
const { title, description, currency, reminder_days, day_count } = body as Record<string, never>;
if (!title) {
throw new HttpException({ error: 'Title is required' }, 400);
}
let start_date: string | null = (body.start_date as string) || null;
let end_date: string | null = (body.end_date as string) || null;
// Presence/shape validation happens in the ZodValidationPipe (tripCreateRequestSchema).
const { title, description, currency, reminder_days, day_count } = body;
let start_date: string | null = body.start_date || null;
let end_date: string | null = body.end_date || null;
if (start_date && !end_date) end_date = toDateStr(addDays(new Date(start_date), 6));
else if (!start_date && end_date) start_date = toDateStr(addDays(new Date(end_date), -6));
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
@@ -124,7 +123,7 @@ export class TripsController {
}
@Put(':id')
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: Record<string, unknown>, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripUpdateDto, @Req() req: Request, @Headers('x-socket-id') socketId?: string) {
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -205,13 +204,14 @@ export class TripsController {
@Post(':id/copy')
@HttpCode(201)
copy(@CurrentUser() user: User, @Param('id') id: string, @Body('title') title: string | undefined, @Req() req: Request) {
copy(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCopyDto, @Req() req: Request) {
if (!this.trips.can('trip_create', user.role, null, user.id, false)) {
throw new HttpException({ error: 'No permission to create trips' }, 403);
}
if (!this.trips.canAccessTrip(id, user.id)) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
const { title } = body;
try {
const newTripId = this.trips.copy(id, user.id, title);
this.audit.writeAudit({ userId: user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(id), newTripId, title } });
@@ -249,7 +249,8 @@ export class TripsController {
@Post(':id/members')
@HttpCode(201)
addMember(@CurrentUser() user: User, @Param('id') id: string, @Body('identifier') identifier: string) {
addMember(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripAddMemberDto) {
const { identifier } = body;
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -286,10 +287,11 @@ export class TripsController {
transferOwnership(
@CurrentUser() user: User,
@Param('id') id: string,
@Body('newOwnerId') newOwnerId: unknown,
@Body() body: TripTransferOwnershipDto,
@Req() req: Request,
@Headers('x-socket-id') socketId?: string,
) {
const { newOwnerId } = body;
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
@@ -299,9 +301,6 @@ export class TripsController {
if (access.user_id !== user.id) {
throw new HttpException({ error: 'Only the owner can transfer ownership' }, 403);
}
if (typeof newOwnerId !== 'number') {
throw new HttpException({ error: 'newOwnerId is required' }, 400);
}
try {
const result = this.trips.transferOwnership(id, newOwnerId, user.id);
this.audit.writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
@@ -330,14 +329,13 @@ export class TripsController {
@Post(':id/guests')
@HttpCode(201)
createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body('name') name: unknown) {
createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body() body: TripCreateGuestDto) {
this.requireOwner(id, user);
if (typeof name !== 'string' || !name.trim()) {
throw new HttpException({ error: 'Guest name is required' }, 400);
}
// Whitespace-only names still 400 with the legacy body — the service throws
// ValidationError('Guest name is required') after trimming.
try {
// No notifyInvite: a guest has no inbox.
return this.trips.createGuest(id, name, user.id);
return this.trips.createGuest(id, body.name, user.id);
} catch (e: unknown) {
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
throw e;
@@ -345,13 +343,10 @@ export class TripsController {
}
@Put(':id/guests/:userId')
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body('name') name: unknown) {
renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: TripRenameGuestDto) {
this.requireOwner(id, user);
if (typeof name !== 'string' || !name.trim()) {
throw new HttpException({ error: 'Guest name is required' }, 400);
}
try {
if (!this.trips.renameGuest(id, parseInt(userId), name)) {
if (!this.trips.renameGuest(id, parseInt(userId), body.name)) {
throw new HttpException({ error: 'Guest not found' }, 404);
}
return { success: true };
+24
View File
@@ -0,0 +1,24 @@
import { createZodDto } from 'nestjs-zod';
import {
tripCreateRequestSchema,
tripUpdateRequestSchema,
tripCopyRequestSchema,
tripAddMemberRequestSchema,
tripTransferOwnershipRequestSchema,
tripCreateGuestRequestSchema,
tripRenameGuestRequestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared trip contracts. The
* global ZodValidationPipe (APP_PIPE in app.module.ts) validates any @Body()
* parameter typed with one of these classes by metatype — the Zod schemas in
* shared/ remain the single source of truth for the wire contract.
*/
export class TripCreateDto extends createZodDto(tripCreateRequestSchema) {}
export class TripUpdateDto extends createZodDto(tripUpdateRequestSchema) {}
export class TripCopyDto extends createZodDto(tripCopyRequestSchema) {}
export class TripAddMemberDto extends createZodDto(tripAddMemberRequestSchema) {}
export class TripTransferOwnershipDto extends createZodDto(tripTransferOwnershipRequestSchema) {}
export class TripCreateGuestDto extends createZodDto(tripCreateGuestRequestSchema) {}
export class TripRenameGuestDto extends createZodDto(tripRenameGuestRequestSchema) {}
+443
View File
@@ -0,0 +1,443 @@
import {
McpController, Tool, Resource, ResourceTemplate, Prompt, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { isAddonEnabled, getCollabFeatures } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, MAX_MCP_TRIP_DAYS, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { canRead, canReadTrips, canDeleteTrips } from '../../mcp/scopes';
import { TripsService, NotFoundError, ValidationError } from './trips.service';
import { TodoService } from '../todo/todo.service';
import { CollabService } from '../collab/collab.service';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
}
function accessDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Trip not found or access denied' }),
}],
};
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
};
}
/**
* Trip MCP surface — ported 1:1 from the legacy registrars: ten tools from
* src/mcp/tools/trips.ts (the three share-link tools moved to
* src/nest/share/share.mcp.ts), the trek://trips, trek://trips/{tripId} and
* trek://trips/{tripId}/members resources from src/mcp/resources.ts, and the
* trip-summary prompt from src/mcp/tools/prompts.ts (identical names,
* descriptions, schemas, annotations, error/payload shapes and broadcasts).
*
* Access markers deviate from the declarative default in two places, on
* purpose (parity with the legacy registration-time gates):
* - the legacy `R` was canReadTrips(), which also accepts trips:delete /
* trips:share tokens — a declarative { group: 'trips', mode: 'read' }
* would silently deny those, so reads use the predicate escape hatch;
* - delete_trip's trips:delete gate has no declarative mode, so it is a
* predicate too. list_trips / get_trip_summary stay unconditionally
* registered (navigation tools — the legacy comment applies unchanged).
*/
@McpController()
export class TripsMcp {
constructor(
private readonly trips: TripsService,
private readonly todos: TodoService,
private readonly collab: CollabService,
) {}
// --- TRIPS ---
@Tool({
name: 'create_trip',
description: 'Create a new trip. Returns the created trip with its generated days.',
inputSchema: {
title: z.string().min(1).max(200).describe('Trip title'),
description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async createTrip(
{ title, description, start_date, end_date, currency }: {
title: string; description?: string; start_date?: string; end_date?: string; currency?: string;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
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 };
}
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 } = this.trips.create(ctx.userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
@Tool({
name: 'update_trip',
description: 'Update an existing trip\'s details.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
date_shift_mode: z.enum(['keep_bookings', 'shift_all']).optional().describe(
'When changing dates: keep_bookings (default) keeps dated reservations/accommodations on their dates while day plans move; shift_all moves the whole itinerary, bookings included'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'trips', mode: 'write' },
})
async updateTrip(
{ tripId, title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }: {
tripId: number; title?: string; description?: string; start_date?: string; end_date?: string;
currency?: string; is_archived?: boolean; cover_image?: string; date_shift_mode?: 'keep_bookings' | 'shift_all';
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, ctx.userId)) return permissionDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
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 };
}
// update() re-anchors the budget before the trip row moves off the old
// currency (#1543) and then runs the legacy updateTrip core.
const { updatedTrip } = await this.trips.update(tripId, ctx.userId, { title, description, start_date, end_date, currency, is_archived, cover_image, date_shift_mode }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
@Tool({
name: 'delete_trip',
description: 'Delete a trip. Only the trip owner can delete it.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: (ctx) => canDeleteTrips(ctx.scopes),
})
async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.isOwner(tripId, ctx.userId)) return noAccess();
this.trips.remove(tripId, ctx.userId, 'user');
return ok({ success: true, tripId });
}
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
// they are navigation tools that any MCP client needs to discover trip IDs.
@Tool({
name: 'list_trips',
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async listTrips({ include_archived }: { include_archived?: boolean }, ctx: McpContext) {
const notice = ctx.getDeprecationNotice ? ctx.getDeprecationNotice() : null;
const trips = this.trips.list(ctx.userId, include_archived ? null : 0);
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
],
};
return ok({ trips });
}
// --- TRIP SUMMARY ---
@Tool({
name: 'get_trip_summary',
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
})
async getTripSummary({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const summary = this.trips.getTripSummary(tripId, ctx.userId);
if (!summary) return noAccess();
const R = canReadTrips(ctx.scopes);
// Addon availability gates
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
const collabFeatures = collabEnabled ? getCollabFeatures() : null;
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
// Core trip data (metadata, days, members, accommodations) is always included
// because this tool is always registered and needed for navigation.
const canReadBudget = budgetEnabled && canRead(ctx.scopes, 'budget');
const canReadPacking = packingEnabled && canRead(ctx.scopes, 'packing');
const canReadCollab = collabEnabled && canRead(ctx.scopes, 'collab');
const canReadTodos = packingEnabled && canRead(ctx.scopes, 'todos');
const canReadRes = canRead(ctx.scopes, 'reservations');
const todos = canReadTodos ? this.todos.listItems(tripId) : [];
let pollCount = 0;
let messageCount = 0;
if (canReadCollab) {
if (collabFeatures?.polls) pollCount = this.collab.listPolls(tripId).length;
if (collabFeatures?.chat) messageCount = this.collab.countMessages(tripId);
}
const notice = ctx.getDeprecationNotice ? ctx.getDeprecationNotice() : null;
// The core bucket (trip metadata, members WITH email, days with place
// coordinates, accommodations) carries confidential PII and itinerary data,
// so it is gated on trips:read just like the sub-sections below. Without a
// read scope the tool still resolves trip id + title so it stays usable for
// navigation (list_trips already covers discovery). trek_ PATs (null scopes)
// and any trips:read holder keep the full payload — no behaviour change.
const summaryData = {
trip: R ? summary.trip : { id: summary.trip.id, title: summary.trip.title },
members: R ? summary.members : undefined,
days: R ? summary.days : undefined,
// Accommodations are "accommodation details" under reservations:read too
// (see SCOPE_INFO) and pair with reservations in the share payload, so a
// reservations-scoped token keeps them — gate on either read scope.
accommodations: (R || canReadRes) ? summary.accommodations : undefined,
reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [],
todos,
pollCount,
messageCount,
};
if (notice) return {
isError: true as const,
content: [
{ type: 'text' as const, text: notice },
{ type: 'text' as const, text: JSON.stringify(summaryData, null, 2) },
],
};
return ok(summaryData);
}
// --- TRIP MEMBERS, COPY, ICS ---
@Tool({
name: 'list_trip_members',
description: 'List all members of a trip (owner + collaborators).',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canReadTrips(ctx.scopes),
})
async listTripMembers({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow) return noAccess();
const { owner, members } = this.trips.listMembers(tripId, ownerRow.user_id);
return ok({ owner, members });
}
@Tool({
name: 'add_trip_member',
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
identifier: z.string().min(1).describe('Username or email of the user to add'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
try {
const result = this.trips.addMember(tripId, identifier, ownerRow.user_id, ctx.userId);
safeBroadcast(tripId, 'member:added', { member: result.member });
return ok({ member: result.member });
} catch (err) {
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
}
@Tool({
name: 'remove_trip_member',
description: 'Remove a member from a trip. Only the trip owner can do this.',
inputSchema: {
tripId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member to remove'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: { group: 'trips', mode: 'write' },
})
async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
const ownerRow = this.trips.getOwner(tripId);
if (!ownerRow || ownerRow.user_id !== ctx.userId)
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
this.trips.removeMember(tripId, memberId);
safeBroadcast(tripId, 'member:removed', { userId: memberId });
return ok({ success: true });
}
@Tool({
name: 'copy_trip',
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
inputSchema: {
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'trips', mode: 'write' },
})
async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
try {
const newTripId = this.trips.copy(tripId, ctx.userId, title);
const newTrip = this.trips.canAccessTrip(newTripId, ctx.userId);
return ok({ trip: { id: newTripId, ...newTrip } });
} catch {
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
}
}
@Tool({
name: 'export_trip_ics',
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: (ctx) => canReadTrips(ctx.scopes),
})
async exportTripIcs({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess();
try {
const { ics, filename } = this.trips.exportICS(tripId);
return ok({ ics, filename });
} catch {
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
}
}
// --- RESOURCES ---
@Resource({
name: 'trips',
uri: 'trek://trips',
description: 'All trips the user owns or is a member of',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripsResource(uri: URL, ctx: McpContext) {
const trips = this.trips.list(ctx.userId, 0);
return jsonContent(uri.href, trips);
}
@ResourceTemplate({
name: 'trip',
uriTemplate: 'trek://trips/{tripId}',
description: 'A single trip with metadata and member count',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href);
const trip = this.trips.get(id, ctx.userId);
return jsonContent(uri.href, trip);
}
@ResourceTemplate({
name: 'trip-members',
uriTemplate: 'trek://trips/{tripId}/members',
description: 'Owner and collaborators of a trip',
mimeType: 'application/json',
access: (ctx) => canReadTrips(ctx.scopes),
})
async tripMembersResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.trips.canAccessTrip(id, ctx.userId)) return accessDenied(uri.href);
const ownerRow = this.trips.getOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = this.trips.listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
// --- PROMPTS ---
@Prompt({
name: 'trip-summary',
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
})
async tripSummaryPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
const summary = this.trips.getTripSummary(tripId, ctx.userId);
if (!summary) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collab_notes } = summary as any;
const memberList = [members?.owner, ...(members?.collaborators || [])].filter(Boolean);
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${memberList.length} (${memberList.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packing?.checked || 0}/${packing?.total || 0} items packed
Budget: ${budget?.total || 0} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collab_notes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text } }],
};
}
}
+7 -2
View File
@@ -1,20 +1,25 @@
import { Module } from '@nestjs/common';
import { BudgetModule } from '../budget/budget.module';
import { CollabModule } from '../collab/collab.module';
import { DaysModule } from '../days/days.module';
import { FilesModule } from '../files/files.module';
import { PackingModule } from '../packing/packing.module';
import { ReservationsModule } from '../reservations/reservations.module';
import { TodoModule } from '../todo/todo.module';
import { VacayModule } from '../vacay/vacay.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AuditModule } from '../audit/audit.module';
import { TripsController } from './trips.controller';
import { TripsService } from './trips.service';
import { TripsMcp } from './trips.mcp';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */
@Module({
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule],
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule],
controllers: [TripsController],
providers: [TripsService],
providers: [TripsService, TripsMcp],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
exports: [TripsService],
})
export class TripsModule {}
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -42,10 +42,13 @@ const SAMPLE_ICS =
'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' +
'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' +
'END:VCALENDAR\r\n';
const { exportICS } = vi.hoisted(() => ({ exportICS: vi.fn() }));
vi.mock('../../src/services/tripService', () => ({ exportICS }));
// Since the trip fold, FeedsService injects TripsService — the mock is a spy on
// the container singleton (created in beforeAll, after build()).
const exportICS = vi.fn();
import { FeedsModule } from '../../src/nest/feeds/feeds.module';
import { TripsService } from '../../src/nest/trips/trips.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
const BASE = 'https://trek.example.test';
@@ -56,7 +59,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
let prevAppUrl: string | undefined;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, FeedsModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -70,6 +73,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
seedUser(db as never, { id: 1, username: 'e2e-user' });
seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' });
app = await build();
vi.spyOn(app.get(TripsService), 'exportICS').mockImplementation(exportICS as never);
server = app.getHttpServer();
});
+56 -24
View File
@@ -1,8 +1,8 @@
/**
* Trips module e2e — exercises the migrated /api/trips aggregate-root endpoints
* through the real JwtAuthGuard against a temp SQLite db. tripService, the bundle
* list-services, auditLog, demo, the permission check, canAccessTrip and the
* WebSocket broadcast are mocked.
* through the real JwtAuthGuard against a temp SQLite db. TripsService runs its
* real (DI-native) SQL — trips/trip_members/days DDL below; auditLog, demo, the
* permission check, canAccessTrip and the WebSocket broadcast are mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
@@ -21,7 +21,25 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
avatar TEXT, display_name TEXT, is_guest INTEGER NOT NULL DEFAULT 0);`);
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
// TripsService runs its real SQL (DI-native since the trip fold) — full trip
// column set for TRIP_SELECT + create/update/delete, plus the membership and
// day-content tables generateDays/listMembers/deleteTrip touch.
tmp.exec(`CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL,
description TEXT, start_date TEXT, end_date TEXT, currency TEXT DEFAULT 'EUR', is_archived INTEGER DEFAULT 0,
cover_image TEXT, reminder_days INTEGER DEFAULT 3, feed_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE trip_members (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
user_id INTEGER NOT NULL, invited_by INTEGER, added_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT, reservation_status TEXT,
reservation_notes TEXT, reservation_datetime TEXT, assignment_time TEXT, assignment_end_time TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE day_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL, text TEXT, time TEXT, icon TEXT, sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
// deleteTrip cleans up synced journey entries before dropping the trip row.
tmp.exec(`CREATE TABLE journey_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, journey_id INTEGER,
source_trip_id INTEGER, source_place_id INTEGER, type TEXT NOT NULL);`);
// bundle()'s todoItems now runs TodoService's real SQL (DI-injected, no mock).
tmp.exec(`CREATE TABLE todo_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0,
@@ -92,15 +110,8 @@ import { PermissionsService } from '../../src/nest/permissions/permissions.servi
// PermissionsService singleton (created in beforeAll, after build()).
let checkPermission: MockInstance;
const { tripSvc } = vi.hoisted(() => ({
tripSvc: {
listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
listMembers: vi.fn(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(),
verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {}, TRIP_SELECT: 'SELECT',
},
}));
vi.mock('../../src/services/tripService', () => tripSvc);
// TripsService itself is real since the trip fold — no tripService mock; the
// trips/trip_members/days DDL above serves its SQL.
// bundle()'s days + accommodations now run DaysService's real SQL (DI-injected,
// no mock) — the days/places/day_accommodations/reservations DDL above serves them.
vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] }));
@@ -131,13 +142,13 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
vi.spyOn(app.get(BudgetService), 'listBudgetItems').mockReturnValue([]);
vi.spyOn(app.get(BudgetService), 'rebaseTripCurrency').mockResolvedValue();
server = app.getHttpServer();
tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]);
tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined));
tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] });
});
beforeEach(() => {
db.prepare('DELETE FROM trips').run();
db.prepare('DELETE FROM trip_members').run();
db.prepare('DELETE FROM days').run();
db.prepare('DELETE FROM audit_log').run();
canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true);
});
@@ -146,20 +157,28 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
await app.close();
});
const seedTrip = (title = 'T', userId = 1) =>
Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (?, ?)').run(userId, title).lastInsertRowid);
it('401 without a cookie', async () => {
expect((await request(server).get('/api/trips')).status).toBe(401);
});
it('200 list', async () => {
it('200 list (real TRIP_SELECT: is_owner + counts)', async () => {
const tripId = seedTrip('T');
const res = await request(server).get('/api/trips').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ trips: [{ id: 1, title: 'T' }] });
expect(res.body.trips).toHaveLength(1);
expect(res.body.trips[0]).toMatchObject({ id: tripId, title: 'T', is_owner: 1, day_count: 0, place_count: 0, shared_count: 0 });
});
it('201 create, 403 without permission', async () => {
it('201 create (real insert + day generation), 403 without permission', async () => {
const ok = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ trip: { id: 9 } });
// The dateless create seeds the default 7 placeholder days.
expect(ok.body.trip).toMatchObject({ title: 'T', currency: 'EUR', day_count: 7, is_owner: 1 });
const dayRows = db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = ?').get(ok.body.trip.id) as { n: number };
expect(dayRows.n).toBe(7);
// The DI-native AuditService wrote the real row (audit_log DDL above).
const audit = db.prepare("SELECT user_id FROM audit_log WHERE action = 'trip.create'").get() as { user_id: number };
expect(audit).toEqual({ user_id: 1 });
@@ -174,9 +193,22 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('200 bundle for an accessible trip', async () => {
const res = await request(server).get('/api/trips/9/bundle').set('Cookie', sessionCookie(1));
it('200 bundle for an accessible trip (real member list)', async () => {
const tripId = seedTrip('B');
const res = await request(server).get(`/api/trips/${tripId}/bundle`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ trip: { id: 9 }, days: [], members: [{ id: 1 }] });
expect(res.body).toMatchObject({ trip: { id: tripId }, days: [], members: [{ id: 1, role: 'owner' }] });
});
it('200 delete cleans up synced journey entries (real SQL)', async () => {
const tripId = seedTrip('D');
db.prepare("INSERT INTO journey_entries (journey_id, source_trip_id, type) VALUES (1, ?, 'skeleton')").run(tripId);
const filledId = Number(db.prepare("INSERT INTO journey_entries (journey_id, source_trip_id, type) VALUES (1, ?, 'story')").run(tripId).lastInsertRowid);
const res = await request(server).delete(`/api/trips/${tripId}`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(db.prepare('SELECT id FROM trips WHERE id = ?').get(tripId)).toBeUndefined();
expect(db.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined();
expect((db.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as { source_trip_id: number | null }).source_trip_id).toBeNull();
});
});
+4 -2
View File
@@ -34,10 +34,12 @@ export interface McpHarnessOptions {
scopes?: string[] | null;
/** Whether the session is authenticated via a static API token (default: false) */
isStaticToken?: boolean;
/** Fire-once deprecation-notice closure (default: registerTools' () => null) */
getDeprecationNotice?: () => string | null;
}
export async function createMcpHarness(options: McpHarnessOptions): Promise<McpHarness> {
const { userId, withResources = true, withTools = true, scopes = null, isStaticToken = false } = options;
const { userId, withResources = true, withTools = true, scopes = null, isStaticToken = false, getDeprecationNotice } = options;
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
@@ -48,7 +50,7 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
// builds the same registry by hand (see mcp-test-controllers.ts).
// registerTools' own ctx construction stays exercised.
setMcpRegistry(createMcpTestRegistry());
registerTools(server, userId, scopes ?? null, isStaticToken);
registerTools(server, userId, scopes ?? null, isStaticToken, getDeprecationNotice);
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+27 -3
View File
@@ -22,8 +22,14 @@ import { ReservationsMcp } from '../../src/nest/reservations/reservations.mcp';
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service';
import { SettingsService } from '../../src/nest/settings/settings.service';
import { ShareMcp } from '../../src/nest/share/share.mcp';
import { ShareService } from '../../src/nest/share/share.service';
import { TodoMcp } from '../../src/nest/todo/todo.mcp';
import { TodoService } from '../../src/nest/todo/todo.service';
import { FilesService } from '../../src/nest/files/files.service';
import { TripsMcp } from '../../src/nest/trips/trips.mcp';
import { TripsService } from '../../src/nest/trips/trips.service';
import { VacayMcp } from '../../src/nest/vacay/vacay.mcp';
import { VacayService } from '../../src/nest/vacay/vacay.service';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
@@ -42,19 +48,37 @@ export function createMcpTestRegistry(): McpRegistry {
const daysService = new DaysService(dbService, permissionsService, realtimeService);
const exchangeRatesService = new ExchangeRatesService();
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService);
const todoService = new TodoService(dbService, permissionsService, realtimeService);
const packingService = new PackingService(dbService, permissionsService, realtimeService);
const collabService = new CollabService(dbService, permissionsService, realtimeService);
const tripsService = new TripsService(
dbService,
todoService,
packingService,
new FilesService(dbService, permissionsService, realtimeService),
new ReservationsService(dbService, permissionsService, budgetService, realtimeService),
daysService,
permissionsService,
budgetService,
collabService,
new VacayService(dbService, realtimeService),
realtimeService,
);
return createTestRegistry(
[
new TagsMcp(new TagsService(dbService)),
new CategoriesMcp(new CategoriesService(dbService)),
new TodoMcp(new TodoService(dbService, permissionsService, realtimeService)),
new PackingMcp(new PackingService(dbService, permissionsService, realtimeService)),
new TodoMcp(todoService),
new PackingMcp(packingService),
new BudgetMcp(budgetService, exchangeRatesService, dbService),
new ReservationsMcp(new ReservationsService(dbService, permissionsService, budgetService, realtimeService), daysService, budgetService),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService)),
new DaysMcp(daysService, dbService),
new AssignmentsMcp(new AssignmentsService(dbService, permissionsService, realtimeService), daysService),
new CollabMcp(new CollabService(dbService, permissionsService, realtimeService)),
new CollabMcp(collabService),
new VacayMcp(new VacayService(dbService, realtimeService)),
new TripsMcp(tripsService, todoService, collabService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService)),
],
{ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess },
);
+10 -110
View File
@@ -1,7 +1,9 @@
/**
* Unit tests for MCP resources (resources.ts).
* Tests all 6 legacy-registrar resources via InMemoryTransport + Client
* (trek://trips/{tripId}/budget, .../budget/per-person and
* Tests the remaining legacy-registrar resources via InMemoryTransport + Client
* (trek://trips, trek://trips/{tripId} and .../members moved to the
* DI-discovered TripsMcp — see tools-trips.test.ts;
* trek://trips/{tripId}/budget, .../budget/per-person and
* .../budget/settlement moved to the DI-discovered BudgetMcp — see
* tools-budget.test.ts and tools-budget-advanced.test.ts;
* trek://categories moved to the DI-discovered CategoriesMcp — see
@@ -76,85 +78,10 @@ async function withHarness(userId: number, fn: (harness: McpHarness) => Promise<
}
}
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();
});
});
});
// trek://trips and trek://trips/{tripId} moved to tools-trips.test.ts: they
// now register via the nest-mcp registry inside registerTools (TripsMcp
// @Resource/@ResourceTemplate), which this file's withTools: false harness
// never attaches.
// The trek://trips/{tripId}/days resource moved to the DI-discovered
// DaysMcp — see tools-days.test.ts.
@@ -205,35 +132,8 @@ describe('Resource: trek://trips/{tripId}/places', () => {
// The trek://trips/{tripId}/accommodations resource moved to the
// DI-discovered DaysMcp — see tools-days-accommodations.test.ts.
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();
});
});
});
// trek://trips/{tripId}/members moved to tools-trips.test.ts (TripsMcp
// @ResourceTemplate) for the same reason.
// trek://trips/{tripId}/collab-notes moved to tools-notes.test.ts: it now
// registers via the nest-mcp registry inside registerTools (CollabMcp
+21 -1
View File
@@ -50,7 +50,8 @@ vi.mock('../../../src/nest/addons/addons.bridge', () => ({
const { mockGetTripSummary } = vi.hoisted(() => ({
mockGetTripSummary: vi.fn(),
}));
vi.mock('../../../src/services/tripService', () => ({
// prompts.ts consumes getTripSummary via the trips bridge since the trip fold.
vi.mock('../../../src/nest/trips/trips.bridge', () => ({
getTripSummary: mockGetTripSummary,
}));
@@ -59,6 +60,21 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories';
import { registerMcpPrompts } from '../../../src/mcp/tools/prompts';
import { createTestRegistry } from '@trek/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import { TripsMcp } from '../../../src/nest/trips/trips.mcp';
import type { TripsService } from '../../../src/nest/trips/trips.service';
import type { TodoService } from '../../../src/nest/todo/todo.service';
import type { CollabService } from '../../../src/nest/collab/collab.service';
// The trip-summary prompt moved to the DI-discovered TripsMcp — its cases below
// exercise it through a hand-built registry over a stub TripsService whose
// getTripSummary is the same controllable mock the legacy path used.
const tripsStub = {
canAccessTrip: (tripId: number, userId: number) => dbMock.canAccessTrip(tripId, userId),
getTripSummary: (tripId: number, viewerUserId?: number) => mockGetTripSummary(tripId, viewerUserId),
} as unknown as TripsService;
const tripsMcp = new TripsMcp(tripsStub, { listItems: () => [] } as unknown as TodoService, { listPolls: () => [], countMessages: () => 0 } as unknown as CollabService);
beforeAll(() => {
createTables(testDb);
@@ -110,6 +126,10 @@ afterAll(() => {
function buildServer(userId: number, opts: { isStaticToken?: boolean } = {}): McpServer {
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
registerMcpPrompts(server, userId, opts.isStaticToken ?? false);
// Attach the DI-discovered trip surface (carries the trip-summary @Prompt)
// the same way registerTools does in production.
createTestRegistry([tripsMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })
.attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false });
return server;
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Unit tests for the share-link MCP surface (ShareMcp, DI-discovered):
* get_share_link, create_share_link, delete_share_link — moved here from the
* legacy src/mcp/tools/trips.ts registrar with the trip DI port. All three
* ride the canShareTrips predicate (no declarative trips:share mode exists).
*/
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, createTrip } 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(); }
}
describe('Tool: get_share_link', () => {
it('returns null when the trip has no share link, then the token after creation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const empty = parseToolResult(await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } })) as any;
expect(empty.link).toBeNull();
const created = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any;
const link = parseToolResult(await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } })) as any;
expect(link.link.token).toBe(created.token);
});
});
it('returns access denied for a non-member trip', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
describe('Tool: create_share_link', () => {
it('creates with the legacy defaults, then updates in place (created=false)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const first = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })) as any;
expect(first.created).toBe(true);
const row = testDb.prepare('SELECT share_map, share_bookings, share_packing, share_budget, share_collab FROM share_tokens WHERE trip_id = ?').get(trip.id) as any;
expect(row).toEqual({ share_map: 1, share_bookings: 1, share_packing: 0, share_budget: 0, share_collab: 0 });
const second = parseToolResult(await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id, share_budget: true } })) as any;
expect(second.created).toBe(false);
expect(second.token).toBe(first.token);
});
});
it('denies without trip access; blocks demo users', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const foreign = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
expect((await h.client.callTool({ name: 'create_share_link', arguments: { tripId: foreign.id } })).isError).toBe(true);
});
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, demo.id);
await withHarness(demo.id, async (h) => {
expect((await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } })).isError).toBe(true);
});
});
});
describe('Tool: delete_share_link', () => {
it('revokes the link; denies without access', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
const result = parseToolResult(await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })) as any;
expect(result.success).toBe(true);
expect(testDb.prepare('SELECT id FROM share_tokens WHERE trip_id = ?').get(trip.id)).toBeUndefined();
});
const { user: stranger } = createUser(testDb);
await withHarness(stranger.id, async (h) => {
expect((await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } })).isError).toBe(true);
});
});
});
+210 -2
View File
@@ -1,5 +1,11 @@
/**
* Unit tests for MCP trip tools: create_trip, update_trip, delete_trip, list_trips, get_trip_summary.
* Unit tests for the trip MCP surface (TripsMcp, DI-discovered): create_trip,
* update_trip, delete_trip, list_trips, get_trip_summary tools, the
* trek://trips / trek://trips/{tripId} resources (moved here from
* resources.test.ts with the DI port — the withTools harness attaches the
* nest-mcp registry that now registers them), the fire-once deprecation
* notice riding the attach ctx, and the scope gating (declarative
* trips:write markers + the canReadTrips/canDeleteTrips predicates).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -36,7 +42,7 @@ 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';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
@@ -503,3 +509,205 @@ describe('Tool: get_trip_summary', () => {
});
});
});
// ---------------------------------------------------------------------------
// Resources (moved from resources.test.ts with the DI port — the registry the
// withTools harness attaches now registers them)
// ---------------------------------------------------------------------------
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}/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();
});
});
});
// ---------------------------------------------------------------------------
// Deprecation notice (fire-once closure riding the registry attach ctx)
// ---------------------------------------------------------------------------
describe('static-token deprecation notice', () => {
it('list_trips surfaces the notice exactly once and still carries the payload', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Noticed' });
// Mirror the fire-once closure built per session in src/mcp/index.ts.
let emitted = false;
const getDeprecationNotice = () => {
if (emitted) return null;
emitted = true;
return 'static tokens are deprecated';
};
const h = await createMcpHarness({ userId: user.id, withResources: false, isStaticToken: true, getDeprecationNotice });
try {
const first = await h.client.callTool({ name: 'list_trips', arguments: {} });
expect(first.isError).toBe(true);
const texts = (first.content as { type: string; text: string }[]).map((c) => c.text);
expect(texts[0]).toContain('deprecated');
expect(JSON.parse(texts[1]).trips[0].title).toBe('Noticed');
// Second call: the closure already fired — plain payload, no error.
const second = await h.client.callTool({ name: 'list_trips', arguments: {} });
expect(second.isError).toBeFalsy();
expect((parseToolResult(second) as any).trips).toHaveLength(1);
} finally {
await h.cleanup();
}
});
});
// ---------------------------------------------------------------------------
// Scope gating — declarative trips:write markers plus the predicate escape
// hatches (canReadTrips accepts trips:delete / trips:share; delete_trip rides
// trips:delete, the share tools trips:share)
// ---------------------------------------------------------------------------
describe('scope gating', () => {
async function toolNames(scopes: string[] | null): Promise<string[]> {
const h = await createMcpHarness({ userId: 1, withResources: false, scopes });
try {
const { tools } = await h.client.listTools();
return tools.map((t) => t.name);
} finally {
await h.cleanup();
}
}
const WRITE_TOOLS = ['create_trip', 'update_trip', 'add_trip_member', 'remove_trip_member', 'copy_trip'];
const READ_TOOLS = ['list_trip_members', 'export_trip_ics'];
const NAV_TOOLS = ['list_trips', 'get_trip_summary'];
const SHARE_TOOLS = ['get_share_link', 'create_share_link', 'delete_share_link'];
it('null scopes (trek_ PAT) exposes the full surface', async () => {
const names = await toolNames(null);
for (const t of [...WRITE_TOOLS, ...READ_TOOLS, ...NAV_TOOLS, 'delete_trip', ...SHARE_TOOLS]) {
expect(names).toContain(t);
}
});
it('trips:read exposes reads + navigation but no writes/delete/share', async () => {
const names = await toolNames(['trips:read']);
for (const t of [...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t);
for (const t of [...WRITE_TOOLS, 'delete_trip', ...SHARE_TOOLS]) expect(names).not.toContain(t);
});
it('trips:delete alone still grants the trip reads (canReadTrips parity) plus delete_trip', async () => {
const names = await toolNames(['trips:delete']);
expect(names).toContain('delete_trip');
for (const t of [...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t);
for (const t of WRITE_TOOLS) expect(names).not.toContain(t);
});
it('trips:share alone grants the share tools and the trip reads, nothing else', async () => {
const names = await toolNames(['trips:share']);
for (const t of [...SHARE_TOOLS, ...READ_TOOLS, ...NAV_TOOLS]) expect(names).toContain(t);
for (const t of [...WRITE_TOOLS, 'delete_trip']) expect(names).not.toContain(t);
});
it('an unrelated scope keeps only the navigation tools', async () => {
const names = await toolNames(['weather:read']);
for (const t of NAV_TOOLS) expect(names).toContain(t);
for (const t of [...WRITE_TOOLS, ...READ_TOOLS, 'delete_trip', ...SHARE_TOOLS]) expect(names).not.toContain(t);
});
});
@@ -61,8 +61,15 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import * as bridge from '../../../src/nest/budget/budget.bridge';
import { createGuest, deleteGuest } from '../../../src/services/tripService';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TripsService } from '../../../src/nest/trips/trips.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
const budget = new BudgetService(
new DatabaseService(testDb),
@@ -71,6 +78,26 @@ const budget = new BudgetService(
new RealtimeService(),
);
// Guest fixtures come from the DI-native TripsService since the trip fold (was
// an import of the deleted services/tripService); deleteGuest routes through
// the SAME BudgetService domain SQL (removeUserFromBudgetItems) under test.
const dbs = () => new DatabaseService(testDb);
const tripsSvc = new TripsService(
dbs(),
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budget, new RealtimeService()),
new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new PermissionsService(dbs()),
budget,
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new VacayService(dbs(), new RealtimeService()),
new RealtimeService(),
);
const createGuest = tripsSvc.createGuest.bind(tripsSvc);
const deleteGuest = tripsSvc.deleteGuest.bind(tripsSvc);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -376,12 +403,15 @@ describe('rebaseTripCurrency', () => {
});
describe('budget.bridge delegation', () => {
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list through the bridge', () => {
// The listBudgetItems / rebaseTripCurrency bridge exports were pruned when
// their last outside-container consumers (legacy tripService + trips MCP
// registrar) migrated — 015/017 pin the same behavior on the service.
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = budget.createBudgetItem(trip.id, { name: 'Hotel', total_price: 100, member_ids: [user.id] });
const items = bridge.listBudgetItems(trip.id);
const items = budget.listBudgetItems(trip.id);
expect(items.map(i => i.id)).toEqual([item.id]);
expect(items[0].members.map(m => m.user_id)).toEqual([user.id]);
@@ -399,13 +429,13 @@ describe('budget.bridge delegation', () => {
expect(row.persons).toBe(1);
});
it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency through the bridge', async () => {
it('BUDGET-SVC-DB-017: rebaseTripCurrency pins the implicit currency', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("UPDATE trips SET currency = 'EUR' WHERE id = ?").run(trip.id);
const item = budget.createBudgetItem(trip.id, { name: 'Implicit', total_price: 100, members: [{ user_id: user.id }] });
await bridge.rebaseTripCurrency(trip.id, 'RUB');
await budget.rebaseTripCurrency(trip.id, 'RUB');
const row = testDb.prepare('SELECT currency, exchange_rate FROM budget_items WHERE id = ?').get(item.id) as { currency: string | null; exchange_rate: number };
expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR });
+8 -32
View File
@@ -1,9 +1,10 @@
/**
* Unit tests for the DI-native ShareService — SHARE-SVC-001 through
* SHARE-SVC-028 (the SHARE-001..026 range belongs to the HTTP parity suite in
* tests/integration/share.test.ts; these pin the service SQL directly, plus
* the share.bridge delegation). Uses a real in-memory SQLite DB so SQL logic
* is exercised faithfully.
* SHARE-SVC-025 (the SHARE-001..026 range belongs to the HTTP parity suite in
* tests/integration/share.test.ts; these pin the service SQL directly; the
* 026–028 bridge-delegation cases died with share.bridge when the legacy
* share-link tools moved to share.mcp.ts). Uses a real in-memory SQLite DB so
* SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -55,7 +56,6 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { ShareService } from '../../../src/nest/share/share.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../../src/nest/share/share.bridge';
import type { User } from '../../../src/types';
const svc = new ShareService(new DatabaseService(testDb), new SettingsService(new DatabaseService(testDb)), permissionsStub);
@@ -412,30 +412,6 @@ describe('getSharedPlacePhotoPath', () => {
});
});
// ── share.bridge delegation ───────────────────────────────────────────────────
describe('share.bridge delegation', () => {
it('SHARE-SVC-026: createOrUpdateShareLink delegates to ShareService.createOrUpdate', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = createOrUpdateShareLink(String(trip.id), user.id, { share_budget: true });
expect(result.created).toBe(true);
expect(shareRow(trip.id).share_budget).toBe(1);
});
it('SHARE-SVC-027: getShareLink delegates to ShareService.get', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getShareLink(String(trip.id))).toBeNull();
const { token } = svc.createOrUpdate(String(trip.id), user.id, {});
expect(getShareLink(String(trip.id))?.token).toBe(token);
});
it('SHARE-SVC-028: deleteShareLink delegates to ShareService.remove', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {});
deleteShareLink(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
});
});
// SHARE-SVC-026..028 (share.bridge delegation) were deleted with the bridge —
// its last consumer, the legacy share-link tools in src/mcp/tools/trips.ts,
// moved to the DI-discovered share.mcp.ts.
+8 -45
View File
@@ -1,8 +1,9 @@
/**
* Unit tests for the DI-native TodoService — TODO-SVC-001 through TODO-SVC-024
* (001–020 moved 1:1 from the legacy tests/unit/services/todoService.test.ts;
* 021–024 pin the todo.bridge delegation). Uses a real in-memory SQLite DB so
* SQL logic is exercised faithfully.
* Unit tests for the DI-native TodoService — TODO-SVC-001 through TODO-SVC-020
* (moved 1:1 from the legacy tests/unit/services/todoService.test.ts; the
* 021–024 bridge-delegation cases died with todo.bridge when the legacy trips
* registrar migrated). Uses a real in-memory SQLite DB so SQL logic is
* exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -46,7 +47,6 @@ import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { listItems, createItem, updateItem, deleteItem } from '../../../src/nest/todo/todo.bridge';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
const svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
@@ -286,43 +286,6 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => {
});
});
// ── todo.bridge (delegation to the service — inside the src/nest coverage gate)
describe('todo.bridge', () => {
it('TODO-SVC-021 — listItems delegates to the service', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createItem(trip.id, { name: 'Bridged' });
const items = listItems(trip.id) as any[];
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Bridged');
});
it('TODO-SVC-022 — createItem delegates with the legacy defaults', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Via bridge' }) as any;
expect(item.checked).toBe(0);
expect(item.priority).toBe(0);
expect(item.sort_order).toBe(0);
});
it('TODO-SVC-023 — updateItem delegates and keeps the bodyKeys sentinel protocol', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any;
const untouched = updateItem(trip.id, item.id, { name: 'Renamed' }, []) as any;
expect(untouched.due_date).toBe('2026-06-01');
const cleared = updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any;
expect(cleared.due_date).toBeNull();
});
it('TODO-SVC-024 — deleteItem delegates to the service', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Gone' }) as any;
expect(deleteItem(trip.id, item.id)).toBe(true);
expect(deleteItem(trip.id, item.id)).toBe(false);
expect(svc.listItems(trip.id)).toHaveLength(0);
});
});
// TODO-SVC-021..024 (todo.bridge delegation) were deleted with the bridge —
// its last consumer, the legacy get_trip_summary registrar in
// src/mcp/tools/trips.ts, moved to the DI-discovered trips.mcp.ts.
+38 -29
View File
@@ -15,8 +15,9 @@ vi.mock('../../../src/services/unsplashService', () => ({
import { TripsController } from '../../../src/nest/trips/trips.controller';
import type { TripsService } from '../../../src/nest/trips/trips.service';
import { NotFoundError, ValidationError } from '../../../src/services/tripService';
import { NotFoundError, ValidationError } from '../../../src/nest/trips/trips.service';
import type { User } from '../../../src/types';
import { tripCreateRequestSchema, tripTransferOwnershipRequestSchema } from '@trek/shared';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const req = { headers: {} } as Request;
@@ -74,9 +75,12 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
});
describe('POST / (create)', () => {
it('403 without trip_create, 400 without title', () => {
it('403 without trip_create; a missing title 400s in the ZodValidationPipe', () => {
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).create(user, { title: 'T' }, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
expect(thrown(() => tc(svc()).create(user, {}, req))).toEqual({ status: 400, body: { error: 'Title is required' } });
// The hand-rolled 'Title is required' 400 moved into the global pipe
// (trips DTO ratchet) — the schema rejects a missing/empty title.
expect(tripCreateRequestSchema.safeParse({}).success).toBe(false);
expect(tripCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
});
it('infers end_date from start_date (+6 days) and creates', () => {
@@ -186,13 +190,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
describe('POST /:id/copy', () => {
it('403 without trip_create, 404 without access', () => {
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', undefined, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', undefined, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).copy(user, '9', {}, req))).toEqual({ status: 403, body: { error: 'No permission to create trips' } });
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).copy(user, '9', {}, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('copies + returns the new trip', () => {
const s = svc({ copy: vi.fn().mockReturnValue(42), getCopiedTrip: vi.fn().mockReturnValue({ id: 42 }) } as Partial<TripsService>);
expect(tc(s).copy(user, '9', 'Copy', req)).toEqual({ trip: { id: 42 } });
expect(tc(s).copy(user, '9', { title: 'Copy' }, req)).toEqual({ trip: { id: 42 } });
});
});
@@ -227,26 +231,26 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
});
it('POST 403 without member_manage, else adds + notifies', () => {
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 403, body: { error: 'No permission to manage members' } });
expect(thrown(() => tc(svc({ can: vi.fn().mockReturnValue(false) })).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 403, body: { error: 'No permission to manage members' } });
const addMember = vi.fn().mockReturnValue({ member: { id: 2, email: 'bob@x.y' }, targetUserId: 2, tripTitle: 'T' });
const notifyInvite = vi.fn();
const s = svc({ addMember, notifyInvite } as Partial<TripsService>);
expect(tc(s).addMember(user, '9', 'bob@x.y')).toEqual({ member: { id: 2, email: 'bob@x.y' } });
expect(tc(s).addMember(user, '9', { identifier: 'bob@x.y' })).toEqual({ member: { id: 2, email: 'bob@x.y' } });
expect(notifyInvite).toHaveBeenCalledWith('9', user, 2, 'T', 'bob@x.y');
});
it('POST 404 without trip access', () => {
const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) });
expect(thrown(() => tc(s).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(s).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('POST maps NotFoundError to 404, ValidationError to 400, re-throws others', () => {
const nf = svc({ addMember: vi.fn().mockImplementation(() => { throw new NotFoundError('no user'); }) } as Partial<TripsService>);
expect(thrown(() => tc(nf).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 404, body: { error: 'no user' } });
expect(thrown(() => tc(nf).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 404, body: { error: 'no user' } });
const ve = svc({ addMember: vi.fn().mockImplementation(() => { throw new ValidationError('already a member'); }) } as Partial<TripsService>);
expect(thrown(() => tc(ve).addMember(user, '9', 'bob@x.y'))).toEqual({ status: 400, body: { error: 'already a member' } });
expect(thrown(() => tc(ve).addMember(user, '9', { identifier: 'bob@x.y' }))).toEqual({ status: 400, body: { error: 'already a member' } });
const other = svc({ addMember: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
expect(() => tc(other).addMember(user, '9', 'bob@x.y')).toThrow('boom');
expect(() => tc(other).addMember(user, '9', { identifier: 'bob@x.y' })).toThrow('boom');
});
it('DELETE 404 without trip access', () => {
@@ -266,18 +270,20 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
describe('POST /:id/transfer (#973)', () => {
it('404 without trip access', () => {
const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) });
expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('403 when the requester is not the owner', () => {
// access.user_id (5) differs from the requesting user (1)
const s = svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) });
expect(thrown(() => tc(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } });
expect(thrown(() => tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } });
});
it('400 when newOwnerId is not a number', () => {
const s = svc();
expect(thrown(() => tc(s).transferOwnership(user, '9', 'nope' as unknown as number, req))).toEqual({ status: 400, body: { error: 'newOwnerId is required' } });
it('a non-numeric newOwnerId 400s in the ZodValidationPipe', () => {
// The hand-rolled 'newOwnerId is required' 400 moved into the global
// pipe (trips DTO ratchet) — the schema rejects non-numeric ids.
expect(tripTransferOwnershipRequestSchema.safeParse({ newOwnerId: 'nope' }).success).toBe(false);
expect(tripTransferOwnershipRequestSchema.safeParse({}).success).toBe(false);
});
it('transfers, audits and broadcasts the refreshed trip', () => {
@@ -285,37 +291,40 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
const get = vi.fn().mockReturnValue({ id: 9, user_id: 2 });
const broadcast = vi.fn();
const s = svc({ transferOwnership, get, broadcast } as Partial<TripsService>);
expect(tc(s).transferOwnership(user, '9', 2, req, 'sock')).toEqual({ success: true });
expect(tc(s).transferOwnership(user, '9', { newOwnerId: 2 }, req, 'sock')).toEqual({ success: true });
expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id);
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock');
});
it('maps NotFoundError to 404 and ValidationError to 400', () => {
const nf = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new NotFoundError('User not found'); }) } as Partial<TripsService>);
expect(thrown(() => tc(nf).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'User not found' } });
expect(thrown(() => tc(nf).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 404, body: { error: 'User not found' } });
const ve = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new ValidationError('New owner must be a trip member'); }) } as Partial<TripsService>);
expect(thrown(() => tc(ve).transferOwnership(user, '9', 2, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } });
expect(thrown(() => tc(ve).transferOwnership(user, '9', { newOwnerId: 2 }, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } });
});
});
describe('guests (#1362)', () => {
it('404 without access, 403 for a non-owner, 400 without a name; else creates', () => {
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', { name: 'Anna' }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
// access.user_id (5) ≠ requester (1) → not the owner
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } });
expect(thrown(() => tc(svc()).createGuest(user, '9', ' '))).toEqual({ status: 400, body: { error: 'Guest name is required' } });
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', { name: 'Anna' }))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } });
// A whitespace-only name still 400s with the legacy body — the service
// throws after trimming (the schema only enforces 1..50 chars).
const wsGuest = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name is required'); }) } as Partial<TripsService>);
expect(thrown(() => tc(wsGuest).createGuest(user, '9', { name: ' ' }))).toEqual({ status: 400, body: { error: 'Guest name is required' } });
const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } });
const s = svc({ createGuest } as Partial<TripsService>);
expect(tc(s).createGuest(user, '9', 'Anna')).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } });
expect(tc(s).createGuest(user, '9', { name: 'Anna' })).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } });
expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id);
});
it('rename: 403 non-owner, 404 when the guest is missing, else success', () => {
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } });
expect(thrown(() => tc(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } });
const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial<TripsService>);
expect(thrown(() => tc(miss).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 404, body: { error: 'Guest not found' } });
expect(thrown(() => tc(miss).renameGuest(user, '9', '7', { name: 'Bob' }))).toEqual({ status: 404, body: { error: 'Guest not found' } });
const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial<TripsService>);
expect(tc(ok).renameGuest(user, '9', '7', 'Bob')).toEqual({ success: true });
expect(tc(ok).renameGuest(user, '9', '7', { name: 'Bob' })).toEqual({ success: true });
});
it('delete: 403 non-owner, 404 when the guest is missing, else success', () => {
@@ -328,7 +337,7 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
it('maps a ValidationError from createGuest to 400', () => {
const ve = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name must be 50 characters or fewer'); }) } as Partial<TripsService>);
expect(thrown(() => tc(ve).createGuest(user, '9', 'x'.repeat(60)))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } });
expect(thrown(() => tc(ve).createGuest(user, '9', { name: 'x'.repeat(60) }))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } });
});
});
@@ -390,6 +399,6 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
it('POST /:id/copy maps a copy failure to 500', () => {
const s = svc({ copy: vi.fn().mockImplementation(() => { throw new Error('boom'); }) } as Partial<TripsService>);
expect(thrown(() => tc(s).copy(user, '9', undefined, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } });
expect(thrown(() => tc(s).copy(user, '9', {}, req))).toEqual({ status: 500, body: { error: 'Failed to copy trip' } });
});
});
File diff suppressed because it is too large Load Diff
@@ -1,810 +0,0 @@
/**
* Unit tests for tripService — exportICS function (TRIP-SVC-001 through TRIP-SVC-009).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
import { exportICS, generateDays, deleteOldCover, updateTrip, transferOwnership, createGuest, renameGuest, deleteGuest, listMembers, addMember } from '../../../src/services/tripService';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import fs from 'fs';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
const daysSvc = new DaysService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
const createAccommodation = daysSvc.createAccommodation.bind(daysSvc);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function getDays(tripId: number) {
return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number; trip_id: number; day_number: number; date: string | null;
}[];
}
function getAssignments(dayId: number) {
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
function getNotes(dayId: number) {
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('generateDays', () => {
it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' });
// Shift forward 9 days — zero overlap with original dates
generateDays(trip.id, '2025-06-10', '2025-06-14');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14',
]);
// day_number 1 (formerly June 1) now has date June 10 — assignment still attached
const day1 = daysAfter[0];
const day2 = daysAfter[1];
expect(getAssignments(day1.id)).toHaveLength(1);
expect(getAssignments(day1.id)[0].id).toBe(assignment.id);
expect(getNotes(day2.id)).toHaveLength(1);
expect(getNotes(day2.id)[0].id).toBe(note.id);
});
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, daysBefore[3].id, place.id);
createDayAssignment(testDb, daysBefore[4].id, place.id);
// Shrink from 5 to 3 days — surplus days and their content are removed
generateDays(trip.id, '2025-07-01', '2025-07-03');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(3);
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
});
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
expect(getDays(trip.id)).toHaveLength(7);
// Shrink 7 → 5; days 6 and 7 have no content
generateDays(trip.id, '2025-07-01', '2025-07-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
]);
});
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(3);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
// Grow to 5 days
generateDays(trip.id, '2025-08-01', '2025-08-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05',
]);
// Existing day 1 retains its assignment
expect(getAssignments(daysAfter[0].id)).toHaveLength(1);
expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id);
// New days 4 and 5 are empty
expect(getAssignments(daysAfter[3].id)).toHaveLength(0);
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
});
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(4);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id);
// Clear both dates
generateDays(trip.id, null, null);
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(4);
expect(daysAfter.every(d => d.date === null)).toBe(true);
// The assignment on the former day 2 still exists
const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id);
expect(formerDay2).toBeDefined();
expect(getAssignments(formerDay2!.id)).toHaveLength(1);
expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id);
});
it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', () => {
// Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position)
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' });
const daysBefore = getDays(trip.id);
const place = createPlace(testDb, trip.id);
// Assign to each of the 5 days
for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id);
// Shift forward 2 days (partial overlap with original range)
generateDays(trip.id, '2025-10-03', '2025-10-07');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07',
]);
// All 5 assignments survive
for (const day of daysAfter) {
expect(getAssignments(day.id)).toHaveLength(1);
}
});
it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', () => {
// 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days.
// Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed).
// Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug).
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' });
// Insert 2 dateless days directly
const daysBefore = getDays(trip.id);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5);
const allDays = getDays(trip.id);
expect(allDays).toHaveLength(5);
const place = createPlace(testDb, trip.id);
// Put an assignment on the second dateless day (day_number=5) — it should survive
const assignment = createDayAssignment(testDb, allDays[4].id, place.id);
// Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused
// This is the scenario that triggered the UNIQUE collision bug
generateDays(trip.id, '2025-11-01', '2025-11-04');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
const dated = daysAfter.filter(d => d.date !== null);
const dateless = daysAfter.filter(d => d.date === null);
expect(dated).toHaveLength(4);
expect(dateless).toHaveLength(1);
// The remaining dateless day still has its assignment
expect(getAssignments(dateless[0].id)).toHaveLength(1);
expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id);
// All day_numbers are unique 1..5
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
expect(nums).toEqual([1, 2, 3, 4, 5]);
});
it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => {
const { user } = createUser(testDb);
// A 7-day trip, then cleared to dateless placeholders (day_count = 7).
const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' });
generateDays(trip.id, null, null);
const dateless = getDays(trip.id);
expect(dateless).toHaveLength(7);
expect(dateless.every(d => d.date === null)).toBe(true);
// Give the LAST dateless day real content so it must be preserved.
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, dateless[6].id, place.id);
// Now set an explicit 2-day range. The first two dateless days are reused for
// the dates; the four empty leftovers must be removed, the one with content kept.
generateDays(trip.id, '2026-01-10', '2026-01-11');
const daysAfter = getDays(trip.id);
const dated = daysAfter.filter(d => d.date !== null);
const stillDateless = daysAfter.filter(d => d.date === null);
expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']);
// day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7)
expect(daysAfter).toHaveLength(3);
expect(stillDateless).toHaveLength(1);
expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id);
});
});
describe('exportICS', () => {
it('TRIP-SVC-001: returns VCALENDAR wrapper', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'My Vacation',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('END:VCALENDAR');
});
it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Summer Holiday',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250601');
// DTEND is exclusive — the day *after* the last day, or the trip loses a day.
expect(ics).toContain('DTEND;VALUE=DATE:20250608');
expect(ics).toContain('SUMMARY:Summer Holiday');
});
describe('#1453 all-day DTEND is timezone-independent', () => {
const originalTz = process.env.TZ;
afterAll(() => {
process.env.TZ = originalTz;
});
// The old code did `new Date(date + 'T00:00:00')` — no Z, so parsed as *server-local*
// midnight — then setDate(+1) and .toISOString(). East of Greenwich that round-trip
// lands a day early, and since DTEND is exclusive the trip's last day was dropped.
// Only invisible in CI because containers default to TZ=UTC.
for (const tz of ['Europe/Berlin', 'Asia/Tokyo', 'Pacific/Kiritimati', 'America/New_York', 'UTC']) {
it(`TRIP-SVC-002b: DTEND is the day after the last day under TZ=${tz}`, () => {
process.env.TZ = tz;
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'TZ Trip',
start_date: '2026-03-28',
end_date: '2026-03-30',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260328');
expect(ics).toContain('DTEND;VALUE=DATE:20260331');
});
}
it('TRIP-SVC-002c: a per-day all-day summary event has the same exclusive DTEND', () => {
process.env.TZ = 'Asia/Tokyo';
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Day Note Trip' });
const day = createDay(testDb, trip.id, { date: '2026-03-30', day_number: 1 });
createDayNote(testDb, day.id, trip.id, { text: 'Pack the bags' });
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20260330');
expect(ics).toContain('DTEND;VALUE=DATE:20260331');
});
});
it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Morning Flight',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02T09:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('DTSTART;VALUE=DATE');
});
it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Hotel Check-in',
type: 'hotel',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250602');
});
it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG to JFK',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id
);
const { ics } = exportICS(trip.id);
expect(ics).toContain('Airline: Air Test');
expect(ics).toContain('Flight: AT100');
});
it('TRIP-SVC-006: special characters in title are escaped', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' });
const { ics } = exportICS(trip.id);
expect(ics).toContain('Trip\\; First\\, Best');
});
it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => {
expect(() => exportICS(99999)).toThrow();
});
it('TRIP-SVC-008: returns a filename derived from trip title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' });
const { filename } = exportICS(trip.id);
expect(filename).toMatch(/My.Trip.2025\.ics/);
});
it('TRIP-SVC-009: reservation with end time includes DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Afternoon Tour',
type: 'activity',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTEND:20250602T160000');
});
it('TRIP-SVC-010: flight with endpoint times but no reservation_time is included', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG → JFK',
type: 'flight',
});
// Confirmed flights store times per endpoint, never as reservation_time.
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02');
const { ics } = exportICS(trip.id);
expect(ics).toContain('SUMMARY:CDG → JFK');
// Departure endpoint zone drives DTSTART, arrival zone drives DTEND, so the
// subscriber sees TREK's zones instead of their own (#1453).
expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000');
expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000');
expect(ics).not.toContain('DTSTART:20250602T090000');
// Each referenced zone gets a VTIMEZONE definition.
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:America/New_York');
expect(ics).toContain('Route: CDG → JFK');
});
it('TRIP-SVC-010b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' });
const reservation = createReservation(testDb, trip.id, { title: 'CDG → JFK', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id);
const insertEp = testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
// A stored/plugin-written timezone can be any string; it must never reach Intl.
// The bogus zone takes precedence over the coordinates (first.timezone || resolveZone).
insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Not/AZone', '09:00', '2025-06-02');
insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'garbage', '12:00', '2025-06-02');
let ics = '';
expect(() => { ics = exportICS(trip.id).ics; }).not.toThrow();
// Falls back to a floating local time (no TZID) and never emits a bogus VTIMEZONE.
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('TZID=Not/AZone');
expect(ics).not.toContain('garbage');
});
it('TRIP-SVC-011: flight endpoint with no local_date is skipped (relative Day-N trips)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Relative Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Timeless Flight',
type: 'flight',
});
testDb.prepare('UPDATE reservations SET reservation_time=NULL WHERE id=?').run(reservation.id);
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(reservation.id, 'from', 0, 'Origin', 'AAA', 1.0, 1.0, null, '09:00', null);
const { ics } = exportICS(trip.id);
expect(ics).not.toContain('SUMMARY:Timeless Flight');
});
it('TRIP-SVC-012: timed assignment gets a TZID derived from the place coordinates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip' });
const day = createDay(testDb, trip.id, { date: '2025-06-02' });
// Tokyo coordinates → Asia/Tokyo via tz-lookup.
const place = createPlace(testDb, trip.id, { name: 'Senso-ji', lat: 35.7148, lng: 139.7967 });
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb
.prepare('UPDATE day_assignments SET assignment_time=? WHERE id=?')
.run('09:00', assignment.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;TZID=Asia/Tokyo:20250602T090000');
expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Asia/Tokyo');
expect(ics).not.toContain('DTSTART:20250602T090000');
});
});
// ── deleteOldCover — path containment ──────────────────────────────────────────
describe('deleteOldCover', () => {
it('TRIP-SVC-COVER-001: never unlinks outside uploads/covers for a crafted cover_image', () => {
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
try {
// Attacker-controlled values aimed at auth-gated sibling upload dirs.
deleteOldCover('/uploads/files/victim.pdf');
deleteOldCover('/uploads/covers/../files/secret.pdf');
deleteOldCover('/uploads/avatars/someone.png');
for (const call of unlinkSpy.mock.calls) {
const target = String(call[0]);
expect(target).toMatch(/[\\/]uploads[\\/]covers[\\/]/); // stays in covers
expect(target).not.toMatch(/[\\/]files[\\/]/);
expect(target).not.toMatch(/[\\/]avatars[\\/]/);
}
} finally {
existsSpy.mockRestore();
unlinkSpy.mockRestore();
}
});
it('TRIP-SVC-COVER-002: deletes a legitimate cover file', () => {
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
try {
deleteOldCover('/uploads/covers/abc123.jpg');
expect(unlinkSpy).toHaveBeenCalledTimes(1);
expect(String(unlinkSpy.mock.calls[0][0])).toMatch(/[\\/]covers[\\/]abc123\.jpg$/);
} finally {
existsSpy.mockRestore();
unlinkSpy.mockRestore();
}
});
});
describe('resyncReservationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertDatedReservation = (tripId: number, dayId: number, time: string) =>
Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')",
).run(tripId, dayId, time).lastInsertRowid);
it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00');
// Shift the whole range one day forward (days become 2025-06-02..06).
updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
// The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row.
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00');
// Shift far forward so 2025-06-02 is no longer covered by any day.
updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(origDayId);
});
});
describe('resyncAccommodationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => {
const place = createPlace(testDb, tripId, { name: 'Grand Hotel' });
const acc = createAccommodation(tripId, {
place_id: place.id, start_day_id: startDayId, end_day_id: endDayId,
}) as { id: number };
const linkedRes = testDb.prepare(
'SELECT id FROM reservations WHERE accommodation_id = ?',
).get(acc.id) as { id: number };
return { accId: acc.id, linkedResId: linkedRes.id };
};
const getAcc = (id: number) =>
testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as
{ start_day_id: number; end_day_id: number };
const getRes = (id: number) =>
testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as
{ day_id: number | null; reservation_time: string | null };
it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' });
const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13'));
// Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …).
updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11'));
expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13'));
const res = getRes(linkedResId);
expect(res.day_id).toBe(acc.start_day_id);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11');
});
it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const startDayId = dayFor(trip.id, '2025-06-02');
const endDayId = dayFor(trip.id, '2025-06-03');
const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId);
updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(startDayId);
expect(acc.end_day_id).toBe(endDayId);
// The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot.
const res = getRes(linkedResId);
expect(res.day_id).toBe(startDayId);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02');
});
it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')",
).run(trip.id, origDayId).lastInsertRowid);
const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03'));
updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user');
// The booking stays on its day row (now 2025-06-04) and its time follows.
const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as
{ day_id: number; reservation_time: string };
expect(res.day_id).toBe(origDayId);
expect(res.reservation_time).toBe('2025-06-04T19:00:00');
// The accommodation stays glued to its (re-dated) day rows too.
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(origDayId);
});
it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')",
).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid);
updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
});
describe('transferOwnership (#973)', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = transferOwnership(trip.id, member.id, owner.id);
expect(result.toEmail).toBe(member.email);
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
expect(updated.user_id).toBe(member.id);
// New owner no longer sits in trip_members, former owner now does.
const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
expect(memberIds).toContain(owner.id);
expect(memberIds).not.toContain(member.id);
});
it('TRIP-SVC-021: rejects a transfer from a non-owner', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// member (not the owner) attempts the transfer
expect(() => transferOwnership(trip.id, member.id, member.id)).toThrow();
});
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member');
});
it('TRIP-SVC-023: rejects transferring to yourself', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip');
});
});
describe('guest members (#1362)', () => {
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = createGuest(trip.id, ' Anna ', owner.id);
expect(member.username).toBe('Anna');
expect(member.is_guest).toBe(true);
const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any;
expect(row.is_guest).toBe(1);
expect(row.password_hash).toBe('');
expect(row.email).toMatch(/@guests\.invalid$/);
expect(row.role).toBe('user');
// Joined as a trip member.
const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(m).toBeTruthy();
// Surfaces in listMembers with is_guest=true and the typed display name.
const { members } = listMembers(trip.id, owner.id) as any;
const guest = members.find((x: any) => x.id === member.id);
expect(guest.username).toBe('Anna');
expect(guest.is_guest).toBe(true);
});
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const a = createGuest(trip.id, 'Sam', owner.id);
const b = createGuest(trip.id, 'Sam', owner.id);
// both keep the plain display name; only the internal (uuid) username differs
expect(a.member.username).toBe('Sam');
expect(b.member.username).toBe('Sam');
expect(b.member.id).not.toBe(a.member.id);
const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[];
expect(usernames[0].username).not.toBe(usernames[1].username);
});
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
const trip = createTrip(testDb, owner.id);
const { member } = createGuest(trip.id, 'Bob', owner.id);
expect(renameGuest(trip.id, member.id, 'Robert')).toBe(true);
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert');
// A real user cannot be renamed through the guest path…
expect(renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
// …and a guest cannot be renamed from a different trip.
expect(renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
});
it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = createGuest(trip.id, 'Carol', owner.id);
// Real members are not deletable via the guest path.
expect(deleteGuest(trip.id, owner.id)).toBe(false);
expect(deleteGuest(trip.id, member.id)).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined();
});
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = createGuest(trip.id, 'Dora', owner.id);
// The synthetic username/email must not resolve through the invite box.
expect(() => addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found');
// Ownership can never be handed to a guest.
expect(() => transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest');
});
});