mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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 } }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user