mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge remote-tracking branch 'origin/dev' into naver-list-import
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.10",
|
||||
"version": "2.9.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "2.9.10",
|
||||
"version": "2.9.12",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.10",
|
||||
"version": "2.9.12",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
@@ -864,6 +864,26 @@ function runMigrations(db: Database.Database): void {
|
||||
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
|
||||
}
|
||||
},
|
||||
// Migration: Budget category ordering
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS budget_category_order (
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (trip_id, category)
|
||||
);
|
||||
`);
|
||||
// Seed existing categories with alphabetical order
|
||||
const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { trip_id: number; category: string }[];
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
|
||||
let lastTripId = -1;
|
||||
let idx = 0;
|
||||
for (const r of rows) {
|
||||
if (r.trip_id !== lastTripId) { lastTripId = r.trip_id; idx = 0; }
|
||||
ins.run(r.trip_id, r.category, idx++);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
+32
-4
@@ -52,17 +52,22 @@ function countSessionsForUser(userId: number): number {
|
||||
|
||||
const sessionSweepInterval = setInterval(() => {
|
||||
const cutoff = Date.now() - SESSION_TTL_MS;
|
||||
let cleaned = 0;
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.lastActivity < cutoff) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
|
||||
for (const [uid, entry] of rateLimitMap) {
|
||||
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
|
||||
}
|
||||
if (cleaned > 0 || sessions.size > 0) {
|
||||
console.log(`[MCP] Session sweep: cleaned ${cleaned}, active ${sessions.size}`);
|
||||
}
|
||||
}, 10 * 60 * 1000); // sweep every 10 minutes
|
||||
|
||||
// Prevent the interval from keeping the process alive if nothing else is running
|
||||
@@ -112,7 +117,14 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
return;
|
||||
}
|
||||
session.lastActivity = Date.now();
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
try {
|
||||
await session.transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
console.error('[MCP] transport.handleRequest error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal MCP error' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +140,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
|
||||
// Create a new per-user MCP server and session
|
||||
const server = new McpServer({ name: 'trek', version: '1.0.0' });
|
||||
const server = new McpServer({
|
||||
name: 'TREK MCP',
|
||||
version: '1.0.0',
|
||||
capabilities: {
|
||||
resources: { listChanged: true },
|
||||
tools: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
},
|
||||
});
|
||||
registerResources(server, user.id);
|
||||
registerTools(server, user.id);
|
||||
|
||||
@@ -136,14 +156,22 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
|
||||
console.log(`[MCP] Session ${sid} created for user ${user.id}. Active sessions: ${sessions.size}`);
|
||||
},
|
||||
onsessionclosed: (sid) => {
|
||||
sessions.delete(sid);
|
||||
},
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (err) {
|
||||
console.error('[MCP] transport.handleRequest error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal MCP error', detail: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
|
||||
+188
-19
@@ -3,13 +3,18 @@ import { canAccessTrip } from '../db/database';
|
||||
import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService';
|
||||
import { listDays, listAccommodations } from '../services/dayService';
|
||||
import { listPlaces } from '../services/placeService';
|
||||
import { listBudgetItems } from '../services/budgetService';
|
||||
import { listItems as listPackingItems } from '../services/packingService';
|
||||
import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService';
|
||||
import { listItems as listPackingItems, listBags } from '../services/packingService';
|
||||
import { listReservations } from '../services/reservationService';
|
||||
import { listNotes as listDayNotes } from '../services/dayNoteService';
|
||||
import { listNotes as listCollabNotes } from '../services/collabService';
|
||||
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
|
||||
import { listItems as listTodoItems } from '../services/todoService';
|
||||
import { listFiles } from '../services/fileService';
|
||||
import { listCategories } from '../services/categoryService';
|
||||
import { listBucketList, listVisitedCountries } from '../services/atlasService';
|
||||
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
|
||||
import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -41,7 +46,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trips',
|
||||
'trek://trips',
|
||||
{ description: 'All trips the user owns or is a member of' },
|
||||
{ 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);
|
||||
@@ -52,7 +57,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip',
|
||||
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
|
||||
{ description: 'A single trip with metadata and member count' },
|
||||
{ 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);
|
||||
@@ -65,7 +70,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-days',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
|
||||
{ description: 'Days of a trip with their assigned places' },
|
||||
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -79,11 +84,12 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-places',
|
||||
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
|
||||
{ description: 'All places/POIs saved in a trip' },
|
||||
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const places = listPlaces(String(id), {});
|
||||
const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
|
||||
const places = listPlaces(String(id), { assignment: assignment ?? undefined });
|
||||
return jsonContent(uri.href, places);
|
||||
}
|
||||
);
|
||||
@@ -92,7 +98,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-budget',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
|
||||
{ description: 'Budget and expense items for a trip' },
|
||||
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -105,7 +111,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-packing',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
|
||||
{ description: 'Packing checklist for a trip' },
|
||||
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -118,7 +124,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-reservations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
|
||||
{ description: 'Reservations (flights, hotels, restaurants) for a trip' },
|
||||
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -131,7 +137,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'day-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
|
||||
{ description: 'Notes for a specific day in a trip' },
|
||||
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId, dayId }) => {
|
||||
const tId = parseId(tripId);
|
||||
const dId = parseId(dayId);
|
||||
@@ -145,7 +151,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-accommodations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
|
||||
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details' },
|
||||
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -158,7 +164,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-members',
|
||||
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
|
||||
{ description: 'Owner and collaborators of a trip' },
|
||||
{ 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);
|
||||
@@ -173,7 +179,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'trip-collab-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
|
||||
{ description: 'Shared collaborative notes for a trip' },
|
||||
{ description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
@@ -182,11 +188,37 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
// Trip files (active, not trash)
|
||||
server.registerResource(
|
||||
'trip-files',
|
||||
new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }),
|
||||
{ description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const files = listFiles(id, false);
|
||||
return jsonContent(uri.href, files);
|
||||
}
|
||||
);
|
||||
|
||||
// Trip to-do list
|
||||
server.registerResource(
|
||||
'trip-todos',
|
||||
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
|
||||
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const items = listTodoItems(id);
|
||||
return jsonContent(uri.href, items);
|
||||
}
|
||||
);
|
||||
|
||||
// All place categories (global, no trip filter)
|
||||
server.registerResource(
|
||||
'categories',
|
||||
'trek://categories',
|
||||
{ description: 'All available place categories (id, name, color, icon) for use when creating places' },
|
||||
{ description: 'All available place categories (id, name, color, icon) for use when creating places', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const categories = listCategories();
|
||||
return jsonContent(uri.href, categories);
|
||||
@@ -197,7 +229,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'bucket-list',
|
||||
'trek://bucket-list',
|
||||
{ description: 'Your personal travel bucket list' },
|
||||
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const items = listBucketList(userId);
|
||||
return jsonContent(uri.href, items);
|
||||
@@ -208,10 +240,147 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
server.registerResource(
|
||||
'visited-countries',
|
||||
'trek://visited-countries',
|
||||
{ description: 'Countries you have marked as visited in Atlas' },
|
||||
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const countries = listVisitedCountries(userId);
|
||||
return jsonContent(uri.href, countries);
|
||||
}
|
||||
);
|
||||
|
||||
// Budget per-person summary
|
||||
server.registerResource(
|
||||
'trip-budget-per-person',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
|
||||
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const summary = getPerPersonSummary(id);
|
||||
return jsonContent(uri.href, summary);
|
||||
}
|
||||
);
|
||||
|
||||
// Budget settlement
|
||||
server.registerResource(
|
||||
'trip-budget-settlement',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
|
||||
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const settlement = calculateSettlement(id);
|
||||
return jsonContent(uri.href, settlement);
|
||||
}
|
||||
);
|
||||
|
||||
// Packing bags
|
||||
server.registerResource(
|
||||
'trip-packing-bags',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
|
||||
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const bags = listBags(id);
|
||||
return jsonContent(uri.href, bags);
|
||||
}
|
||||
);
|
||||
|
||||
// In-app notifications
|
||||
server.registerResource(
|
||||
'notifications-in-app',
|
||||
'trek://notifications/in-app',
|
||||
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const result = getNotifications(userId, { limit: 50 });
|
||||
return jsonContent(uri.href, result);
|
||||
}
|
||||
);
|
||||
|
||||
// Atlas stats and regions (addon-gated)
|
||||
if (isAddonEnabled('atlas')) {
|
||||
server.registerResource(
|
||||
'atlas-stats',
|
||||
'trek://atlas/stats',
|
||||
{ description: "User's atlas statistics — visited country counts and breakdown", mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const stats = await getAtlasStats(userId);
|
||||
return jsonContent(uri.href, stats);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'atlas-regions',
|
||||
'trek://atlas/regions',
|
||||
{ description: 'List of manually visited regions for the current user', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const regions = listManuallyVisitedRegions(userId);
|
||||
return jsonContent(uri.href, regions);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Collab polls & messages (addon-gated)
|
||||
if (isAddonEnabled('collab')) {
|
||||
server.registerResource(
|
||||
'trip-collab-polls',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
|
||||
{ description: 'All polls for a trip with vote counts per option', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const polls = listPolls(id);
|
||||
return jsonContent(uri.href, polls);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'trip-collab-messages',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }),
|
||||
{ description: 'Most recent 100 chat messages for a trip', mimeType: 'application/json' },
|
||||
async (uri, { tripId }) => {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
|
||||
const messages = listMessages(id);
|
||||
return jsonContent(uri.href, messages);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Vacay resources (addon-gated)
|
||||
if (isAddonEnabled('vacay')) {
|
||||
server.registerResource(
|
||||
'vacay-plan',
|
||||
'trek://vacay/plan',
|
||||
{ description: "Full snapshot of the user's active vacation plan (members, years, settings)", mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const plan = getPlanData(userId);
|
||||
return jsonContent(uri.href, plan);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'vacay-entries',
|
||||
new ResourceTemplate('trek://vacay/entries/{year}', { list: undefined }),
|
||||
{ description: 'All vacation entries for the active plan and a specific year', mimeType: 'application/json' },
|
||||
async (uri, { year }) => {
|
||||
const planId = getActivePlanId(userId);
|
||||
const entries = getVacayEntries(planId, Array.isArray(year) ? year[0] : year);
|
||||
return jsonContent(uri.href, entries);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'vacay-holidays',
|
||||
new ResourceTemplate('trek://vacay/holidays/{year}', { list: undefined }),
|
||||
{ description: "Cached public holidays for the plan's configured region and year", mimeType: 'application/json' },
|
||||
async (uri, { year }) => {
|
||||
const plan = getActivePlan(userId);
|
||||
if (!plan.holidays_enabled || !plan.holidays_region) return jsonContent(uri.href, []);
|
||||
const yearStr = Array.isArray(year) ? year[0] : year;
|
||||
const result = await getHolidays(yearStr, plan.holidays_region);
|
||||
return jsonContent(uri.href, result.data ?? []);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-882
@@ -1,900 +1,48 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { isDemoUser } from '../services/authService';
|
||||
import {
|
||||
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
|
||||
isOwner, verifyTripAccess,
|
||||
} from '../services/tripService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../services/placeService';
|
||||
import { listCategories } from '../services/categoryService';
|
||||
import {
|
||||
dayExists, placeExists, createAssignment, assignmentExistsInDay,
|
||||
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
|
||||
} from '../services/assignmentService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||
import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService';
|
||||
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
|
||||
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService';
|
||||
import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService';
|
||||
import { createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote } from '../services/collabService';
|
||||
import {
|
||||
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
|
||||
} from '../services/atlasService';
|
||||
import { searchPlaces } from '../services/mapsService';
|
||||
|
||||
const MAX_MCP_TRIP_DAYS = 90;
|
||||
|
||||
function demoDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
|
||||
}
|
||||
|
||||
function noAccess() {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
import { registerTodoTools } from './tools/todos';
|
||||
import { registerAssignmentTools } from './tools/assignments';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
import { registerPlaceTools } from './tools/places';
|
||||
import { registerDayTools } from './tools/days';
|
||||
import { registerBudgetTools } from './tools/budget';
|
||||
import { registerPackingTools } from './tools/packing';
|
||||
import { registerCollabTools } from './tools/collab';
|
||||
import { registerTripTools } from './tools/trips';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
|
||||
export function registerTools(server: McpServer, userId: number): void {
|
||||
// --- TRIPS ---
|
||||
registerTripTools(server, userId);
|
||||
|
||||
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)'),
|
||||
},
|
||||
},
|
||||
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 });
|
||||
}
|
||||
);
|
||||
registerPlaceTools(server, userId);
|
||||
|
||||
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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 };
|
||||
}
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
broadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
);
|
||||
registerBudgetTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'delete_trip',
|
||||
{
|
||||
description: 'Delete a trip. Only the trip owner can delete it.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!isOwner(tripId, userId)) return noAccess();
|
||||
deleteTrip(tripId, userId, 'user');
|
||||
return ok({ success: true, tripId });
|
||||
}
|
||||
);
|
||||
registerPackingTools(server, userId);
|
||||
|
||||
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)'),
|
||||
},
|
||||
},
|
||||
async ({ include_archived }) => {
|
||||
const trips = listTrips(userId, include_archived ? null : 0);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
registerReservationTools(server, userId);
|
||||
|
||||
// --- PLACES ---
|
||||
registerDayTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
||||
broadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
registerAssignmentTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
description: 'Update an existing place in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
broadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
registerTagTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'delete_place',
|
||||
{
|
||||
description: 'Delete a place from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
broadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
registerMapsWeatherTools(server, userId);
|
||||
|
||||
// --- CATEGORIES ---
|
||||
registerNotificationTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'list_categories',
|
||||
{
|
||||
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const categories = listCategories();
|
||||
return ok({ categories });
|
||||
}
|
||||
);
|
||||
registerAtlasTools(server, userId);
|
||||
|
||||
// --- SEARCH ---
|
||||
registerCollabTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'search_place',
|
||||
{
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
},
|
||||
},
|
||||
async ({ query }) => {
|
||||
try {
|
||||
const result = await searchPlaces(userId, query);
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
registerVacayTools(server, userId);
|
||||
|
||||
// --- ASSIGNMENTS ---
|
||||
registerTodoTools(server, userId);
|
||||
|
||||
server.registerTool(
|
||||
'assign_place_to_day',
|
||||
{
|
||||
description: 'Assign a place to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
notes: z.string().max(500).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
broadcast(tripId, 'assignment:created', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'unassign_place',
|
||||
{
|
||||
description: 'Remove a place assignment from a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET ---
|
||||
|
||||
server.registerTool(
|
||||
'create_budget_item',
|
||||
{
|
||||
description: 'Add a budget/expense item to a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
broadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_budget_item',
|
||||
{
|
||||
description: 'Delete a budget item from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
broadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- PACKING ---
|
||||
|
||||
server.registerTool(
|
||||
'create_packing_item',
|
||||
{
|
||||
description: 'Add an item to the packing checklist for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
||||
broadcast(tripId, 'packing:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_packing_item',
|
||||
{
|
||||
description: 'Check or uncheck a packing item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
checked: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
broadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_packing_item',
|
||||
{
|
||||
description: 'Remove an item from the packing checklist.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deletePackingItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
broadcast(tripId, 'packing:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- RESERVATIONS ---
|
||||
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
day_id: z.number().int().positive().optional(),
|
||||
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
if (day_id && !getDay(day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
||||
if (place_id && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
broadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
broadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_reservation',
|
||||
{
|
||||
description: 'Delete a reservation from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (accommodationDeleted) {
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
||||
}
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'link_hotel_accommodation',
|
||||
{
|
||||
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The hotel place to link'),
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const current = getReservation(reservationId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
|
||||
if (!placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const isNewAccommodation = !current.accommodation_id;
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
place_id,
|
||||
type: current.type,
|
||||
status: current.status as string,
|
||||
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
||||
}, current);
|
||||
|
||||
broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
broadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
||||
}
|
||||
);
|
||||
|
||||
// --- DAYS ---
|
||||
|
||||
server.registerTool(
|
||||
'update_assignment_time',
|
||||
{
|
||||
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
|
||||
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, assignmentId, place_time, end_time }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const assignment = updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : (existing as any).assignment_time,
|
||||
end_time !== undefined ? end_time : (existing as any).assignment_end_time
|
||||
);
|
||||
broadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_day',
|
||||
{
|
||||
description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, title }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
|
||||
broadcast(tripId, 'day:updated', { day: updated });
|
||||
return ok({ day: updated });
|
||||
}
|
||||
);
|
||||
|
||||
// --- RESERVATIONS (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional(),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
|
||||
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
|
||||
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
|
||||
if (place_id != null && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title, type, reservation_time, location, confirmation_number, notes, status,
|
||||
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
||||
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
||||
}, existing);
|
||||
broadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_budget_item',
|
||||
{
|
||||
description: 'Update an existing budget/expense item in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
total_price: z.number().nonnegative().optional(),
|
||||
persons: z.number().int().positive().nullable().optional(),
|
||||
days: z.number().int().positive().nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
broadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
// --- PACKING (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_packing_item',
|
||||
{
|
||||
description: 'Rename a packing item or change its category.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
||||
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
broadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
// --- REORDER ---
|
||||
|
||||
server.registerTool(
|
||||
'reorder_day_assignments',
|
||||
{
|
||||
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
reorderAssignments(dayId, assignmentIds);
|
||||
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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 totals, packing stats, reservations, and collab notes. Use this as a context loader before planning or modifying a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const summary = getTripSummary(tripId);
|
||||
if (!summary) return noAccess();
|
||||
return ok(summary);
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUCKET LIST ---
|
||||
|
||||
server.registerTool(
|
||||
'create_bucket_list_item',
|
||||
{
|
||||
description: 'Add a destination to your personal travel bucket list.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).max(200).describe('Destination or experience name'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, lat, lng, country_code, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_bucket_list_item',
|
||||
{
|
||||
description: 'Remove an item from your travel bucket list.',
|
||||
inputSchema: {
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const deleted = deleteBucketItem(userId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- ATLAS ---
|
||||
|
||||
server.registerTool(
|
||||
'mark_country_visited',
|
||||
{
|
||||
description: 'Mark a country as visited in your Atlas.',
|
||||
inputSchema: {
|
||||
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
|
||||
},
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
markCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'unmark_country_visited',
|
||||
{
|
||||
description: 'Remove a country from your visited countries in Atlas.',
|
||||
inputSchema: {
|
||||
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
|
||||
},
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
unmarkCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
|
||||
// --- COLLAB NOTES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_collab_note',
|
||||
{
|
||||
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().max(10000).optional(),
|
||||
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, content, category, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = createCollabNote(tripId, userId, { title, content, category, color });
|
||||
broadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_collab_note',
|
||||
{
|
||||
description: 'Edit an existing collaborative note on a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
content: z.string().max(10000).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
|
||||
pinned: z.boolean().optional().describe('Pin the note to the top'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, noteId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
broadcast(tripId, 'collab:note:updated', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_collab_note',
|
||||
{
|
||||
description: 'Delete a collaborative note from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deleteCollabNote(tripId, noteId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
broadcast(tripId, 'collab:note:deleted', { noteId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- DAY NOTES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_day_note',
|
||||
{
|
||||
description: 'Add a note to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const note = createDayNote(dayId, tripId, text, time, icon);
|
||||
broadcast(tripId, 'dayNote:created', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_day_note',
|
||||
{
|
||||
description: 'Edit an existing note on a specific day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500).optional(),
|
||||
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, noteId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getDayNote(noteId, dayId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
|
||||
broadcast(tripId, 'dayNote:updated', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_day_note',
|
||||
{
|
||||
description: 'Delete a note from a specific day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = getDayNote(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
deleteDayNote(noteId);
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
registerMcpPrompts(server, userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { broadcast } from '../../websocket';
|
||||
|
||||
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
broadcast(tripId, event, payload);
|
||||
} catch (err) {
|
||||
console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_MCP_TRIP_DAYS = 90;
|
||||
|
||||
export const TOOL_ANNOTATIONS_READONLY = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_WRITE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_DELETE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export function demoDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
|
||||
}
|
||||
|
||||
export function noAccess() {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
|
||||
}
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
dayExists, placeExists, createAssignment, assignmentExistsInDay,
|
||||
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
|
||||
moveAssignment,
|
||||
getParticipants as getAssignmentParticipants,
|
||||
setParticipants as setAssignmentParticipants,
|
||||
} from '../../services/assignmentService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerAssignmentTools(server: McpServer, userId: number): void {
|
||||
// --- ASSIGNMENTS ---
|
||||
|
||||
server.registerTool(
|
||||
'assign_place_to_day',
|
||||
{
|
||||
description: 'Assign a place to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
notes: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const assignment = createAssignment(dayId, placeId, notes || null);
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'unassign_place',
|
||||
{
|
||||
description: 'Remove a place assignment from a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
deleteAssignment(assignmentId);
|
||||
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_assignment_time',
|
||||
{
|
||||
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
|
||||
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, place_time, end_time }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getAssignmentForTrip(assignmentId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
const assignment = updateTime(
|
||||
assignmentId,
|
||||
place_time !== undefined ? place_time : (existing as any).assignment_time,
|
||||
end_time !== undefined ? end_time : (existing as any).assignment_end_time
|
||||
);
|
||||
safeBroadcast(tripId, 'assignment:updated', { assignment });
|
||||
return ok({ assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'move_assignment',
|
||||
{
|
||||
description: 'Move a place assignment to a different day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
newDayId: z.number().int().positive(),
|
||||
oldDayId: z.number().int().positive(),
|
||||
orderIndex: z.number().int().min(0).optional().default(0),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
|
||||
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
|
||||
return ok({ assignment: result.assignment });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_assignment_participants',
|
||||
{
|
||||
description: 'Get the list of users participating in a specific place assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, assignmentId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const participants = getAssignmentParticipants(assignmentId);
|
||||
return ok({ participants });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'set_assignment_participants',
|
||||
{
|
||||
description: 'Set the participants for a place assignment (replaces current list).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
assignmentId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, assignmentId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const participants = setAssignmentParticipants(assignmentId, userIds);
|
||||
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
|
||||
return ok({ participants });
|
||||
}
|
||||
);
|
||||
|
||||
// --- REORDER ---
|
||||
|
||||
server.registerTool(
|
||||
'reorder_day_assignments',
|
||||
{
|
||||
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
reorderAssignments(dayId, assignmentIds);
|
||||
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
|
||||
getStats as getAtlasStats, listManuallyVisitedRegions,
|
||||
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
|
||||
} from '../../services/atlasService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerAtlasTools(server: McpServer, userId: number): void {
|
||||
// --- BUCKET LIST ---
|
||||
|
||||
server.registerTool(
|
||||
'create_bucket_list_item',
|
||||
{
|
||||
description: 'Add a destination to your personal travel bucket list.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).max(200).describe('Destination or experience name'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ name, lat, lng, country_code, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_bucket_list_item',
|
||||
{
|
||||
description: 'Remove an item from your travel bucket list.',
|
||||
inputSchema: {
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const deleted = deleteBucketItem(userId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- ATLAS ---
|
||||
|
||||
server.registerTool(
|
||||
'mark_country_visited',
|
||||
{
|
||||
description: 'Mark a country as visited in your Atlas.',
|
||||
inputSchema: {
|
||||
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
markCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'unmark_country_visited',
|
||||
{
|
||||
description: 'Remove a country from your visited countries in Atlas.',
|
||||
inputSchema: {
|
||||
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
unmarkCountryVisited(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
|
||||
// --- ATLAS EXPANDED ---
|
||||
|
||||
if (isAddonEnabled('atlas')) {
|
||||
server.registerTool(
|
||||
'get_atlas_stats',
|
||||
{
|
||||
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const stats = await getAtlasStats(userId);
|
||||
return ok({ stats });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_visited_regions',
|
||||
{
|
||||
description: 'List all manually visited sub-country regions for the current user.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const regions = listManuallyVisitedRegions(userId);
|
||||
return ok({ regions });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'mark_region_visited',
|
||||
{
|
||||
description: 'Mark a sub-country region as visited.',
|
||||
inputSchema: {
|
||||
regionCode: z.string().describe('ISO region code e.g. US-CA'),
|
||||
regionName: z.string(),
|
||||
countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ regionCode, regionName, countryCode }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
markRegionVisited(userId, regionCode, regionName, countryCode);
|
||||
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
|
||||
return ok({ region });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'unmark_region_visited',
|
||||
{
|
||||
description: 'Remove a region from the visited list.',
|
||||
inputSchema: {
|
||||
regionCode: z.string(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ regionCode }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
unmarkRegionVisited(userId, regionCode);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_country_atlas_places',
|
||||
{
|
||||
description: 'Get places saved in the user\'s atlas for a specific country.',
|
||||
inputSchema: {
|
||||
countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ countryCode }) => {
|
||||
const result = getCountryPlaces(userId, countryCode);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_bucket_list_item',
|
||||
{
|
||||
description: 'Update a bucket list item (notes, name, target date, location).',
|
||||
inputSchema: {
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
lat: z.number().nullable().optional(),
|
||||
lng: z.number().nullable().optional(),
|
||||
country_code: z.string().optional(),
|
||||
target_date: z.string().nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ itemId, name, notes, lat, lng, country_code, target_date }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const item = updateBucketItem(userId, itemId, { name, notes, lat, lng, country_code, target_date });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
updateMembers as updateBudgetMembers,
|
||||
toggleMemberPaid,
|
||||
} from '../../services/budgetService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerBudgetTools(server: McpServer, userId: number): void {
|
||||
// --- BUDGET ---
|
||||
|
||||
server.registerTool(
|
||||
'create_budget_item',
|
||||
{
|
||||
description: 'Add a budget/expense item to a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_budget_item',
|
||||
{
|
||||
description: 'Delete a budget item from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deleteBudgetItem(itemId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_budget_item',
|
||||
{
|
||||
description: 'Update an existing budget/expense item in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
total_price: z.number().nonnegative().optional(),
|
||||
persons: z.number().int().positive().nullable().optional(),
|
||||
days: z.number().int().positive().nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'budget:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
description: 'Set which trip members are splitting a budget item (replaces current member list).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updateBudgetMembers(itemId, tripId, userIds);
|
||||
safeBroadcast(tripId, 'budget:members-updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_budget_member_paid',
|
||||
{
|
||||
description: 'Mark or unmark a member as having paid their share of a budget item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
memberId: z.number().int().positive().describe('User ID of the member'),
|
||||
paid: z.boolean(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, memberId, paid }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const member = toggleMemberPaid(itemId, memberId, paid);
|
||||
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
|
||||
return ok({ member });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote,
|
||||
listPolls, createPoll, votePoll, closePoll, deletePoll,
|
||||
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
|
||||
} from '../../services/collabService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerCollabTools(server: McpServer, userId: number): void {
|
||||
// --- COLLAB NOTES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_collab_note',
|
||||
{
|
||||
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().max(10000).optional(),
|
||||
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
|
||||
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
|
||||
safeBroadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_collab_note',
|
||||
{
|
||||
description: 'Edit an existing collaborative note on a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
content: z.string().max(10000).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
|
||||
pinned: z.boolean().optional().describe('Pin the note to the top'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, noteId, title, content, category, color, pinned }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:note:updated', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_collab_note',
|
||||
{
|
||||
description: 'Delete a collaborative note from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deleteCollabNote(tripId, noteId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- COLLAB POLLS & CHAT ---
|
||||
|
||||
if (isAddonEnabled('collab')) {
|
||||
server.registerTool(
|
||||
'list_collab_polls',
|
||||
{
|
||||
description: 'List all polls for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const polls = listPolls(tripId);
|
||||
return ok({ polls });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_collab_poll',
|
||||
{
|
||||
description: 'Create a new poll in the collab panel.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
question: z.string().min(1),
|
||||
options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'),
|
||||
multiple: z.boolean().optional().describe('Allow multiple choice'),
|
||||
deadline: z.string().optional().describe('ISO date string for poll deadline'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, question, options, multiple, deadline }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
|
||||
safeBroadcast(tripId, 'collab:poll:created', { poll });
|
||||
return ok({ poll });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'vote_collab_poll',
|
||||
{
|
||||
description: 'Vote on a poll option (or remove vote if already voted for that option).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
pollId: z.number().int().positive(),
|
||||
optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, pollId, optionIndex }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = votePoll(tripId, pollId, userId, optionIndex);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
|
||||
return ok({ poll: result.poll });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'close_collab_poll',
|
||||
{
|
||||
description: 'Close a poll so no more votes can be cast.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
pollId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, pollId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const poll = closePoll(tripId, pollId);
|
||||
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:closed', { poll });
|
||||
return ok({ poll });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_collab_poll',
|
||||
{
|
||||
description: 'Delete a poll and all its votes.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
pollId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, pollId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deletePoll(tripId, pollId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_collab_messages',
|
||||
{
|
||||
description: 'List chat messages for a trip (most recent 100, oldest-first).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, before }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const messages = listMessages(tripId, before);
|
||||
return ok({ messages });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'send_collab_message',
|
||||
{
|
||||
description: "Send a chat message to a trip's collab channel.",
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
text: z.string().min(1),
|
||||
replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, text, replyTo }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = createMessage(tripId, userId, text, replyTo ?? null);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
|
||||
return ok({ message: result.message });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_collab_message',
|
||||
{
|
||||
description: 'Delete a chat message (only the message owner can delete their own messages).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, messageId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = deleteMessage(tripId, messageId, userId);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'react_collab_message',
|
||||
{
|
||||
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
messageId: z.number().int().positive(),
|
||||
emoji: z.string().describe('Single emoji character'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, messageId, emoji }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = addOrRemoveReaction(messageId, tripId, userId, emoji);
|
||||
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
|
||||
return ok({ reactions: result.reactions });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
getDay, updateDay, validateAccommodationRefs,
|
||||
createDay, deleteDay,
|
||||
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
|
||||
} from '../../services/dayService';
|
||||
import {
|
||||
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
|
||||
deleteNote as deleteDayNote, dayExists as dayNoteExists,
|
||||
} from '../../services/dayNoteService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerDayTools(server: McpServer, userId: number): void {
|
||||
// --- DAYS ---
|
||||
|
||||
server.registerTool(
|
||||
'update_day',
|
||||
{
|
||||
description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, dayId, title }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const current = getDay(dayId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
|
||||
safeBroadcast(tripId, 'day:updated', { day: updated });
|
||||
return ok({ day: updated });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_day',
|
||||
{
|
||||
description: 'Add a new day to a trip (optionally with a specific date and notes).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'),
|
||||
notes: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, date, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = createDay(tripId, date, notes);
|
||||
safeBroadcast(tripId, 'day:created', { day });
|
||||
return ok({ day });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_day',
|
||||
{
|
||||
description: 'Delete a day from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
deleteDay(dayId);
|
||||
safeBroadcast(tripId, 'day:deleted', { id: dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_accommodation',
|
||||
{
|
||||
description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The place to use as the accommodation'),
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
|
||||
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
safeBroadcast(tripId, 'accommodation:created', { accommodation });
|
||||
return ok({ accommodation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_accommodation',
|
||||
{
|
||||
description: 'Update fields on an existing accommodation.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
accommodationId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().optional(),
|
||||
start_day_id: z.number().int().positive().optional(),
|
||||
end_day_id: z.number().int().positive().optional(),
|
||||
check_in: z.string().max(10).optional(),
|
||||
check_out: z.string().max(10).optional(),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getAccommodation(accommodationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
|
||||
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
|
||||
return ok({ accommodation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_accommodation',
|
||||
{
|
||||
description: 'Delete an accommodation from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
accommodationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, accommodationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { linkedReservationId } = deleteAccommodation(accommodationId);
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
|
||||
return ok({ success: true, linkedReservationId });
|
||||
}
|
||||
);
|
||||
|
||||
// --- DAY NOTES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_day_note',
|
||||
{
|
||||
description: 'Add a note to a specific day in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const note = createDayNote(dayId, tripId, text, time, icon);
|
||||
safeBroadcast(tripId, 'dayNote:created', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_day_note',
|
||||
{
|
||||
description: 'Edit an existing note on a specific day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
text: z.string().min(1).max(500).optional(),
|
||||
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
icon: z.string().optional().describe('Emoji icon for the note'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, dayId, noteId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getDayNote(noteId, dayId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
|
||||
safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_day_note',
|
||||
{
|
||||
description: 'Delete a note from a specific day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive(),
|
||||
noteId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, dayId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = getDayNote(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
deleteDayNote(noteId);
|
||||
safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerMapsWeatherTools(server: McpServer, userId: number): void {
|
||||
// --- MAPS EXTRAS ---
|
||||
|
||||
server.registerTool(
|
||||
'get_place_details',
|
||||
{
|
||||
description: 'Fetch detailed information about a place by its Google Place ID.',
|
||||
inputSchema: {
|
||||
placeId: z.string().describe('Google Place ID'),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ placeId, lang }) => {
|
||||
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
|
||||
if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true };
|
||||
return ok({ details });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'reverse_geocode',
|
||||
{
|
||||
description: 'Get a human-readable address for given coordinates.',
|
||||
inputSchema: {
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ lat, lng, lang }) => {
|
||||
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true };
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'resolve_maps_url',
|
||||
{
|
||||
description: 'Resolve a Google Maps share URL to coordinates and place name.',
|
||||
inputSchema: {
|
||||
url: z.string().describe('Google Maps share URL'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ url }) => {
|
||||
const result = await resolveGoogleMapsUrl(url);
|
||||
if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
// --- WEATHER ---
|
||||
|
||||
server.registerTool(
|
||||
'get_weather',
|
||||
{
|
||||
description: 'Get weather forecast for a location and date.',
|
||||
inputSchema: {
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
date: z.string().describe('ISO date YYYY-MM-DD'),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ lat, lng, date, lang }) => {
|
||||
try {
|
||||
const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err: any) {
|
||||
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_detailed_weather',
|
||||
{
|
||||
description: 'Get hourly/detailed weather forecast for a location and date.',
|
||||
inputSchema: {
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
date: z.string().describe('ISO date YYYY-MM-DD'),
|
||||
lang: z.string().optional().default('en'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ lat, lng, date, lang }) => {
|
||||
try {
|
||||
const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err: any) {
|
||||
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
getNotifications, getUnreadCount,
|
||||
markRead as markNotificationRead, markUnread as markNotificationUnread,
|
||||
markAllRead,
|
||||
} from '../../services/inAppNotifications';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerNotificationTools(server: McpServer, userId: number): void {
|
||||
// --- NOTIFICATIONS ---
|
||||
|
||||
server.registerTool(
|
||||
'list_notifications',
|
||||
{
|
||||
description: 'List in-app notifications for the current user.',
|
||||
inputSchema: {
|
||||
limit: z.number().int().positive().optional().default(20),
|
||||
offset: z.number().int().min(0).optional().default(0),
|
||||
unread_only: z.boolean().optional().default(false),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ limit, offset, unread_only }) => {
|
||||
const result = getNotifications(userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_unread_notification_count',
|
||||
{
|
||||
description: 'Get the number of unread in-app notifications.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const count = getUnreadCount(userId);
|
||||
return ok({ count });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'mark_notification_read',
|
||||
{
|
||||
description: 'Mark a single notification as read.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ notificationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = markNotificationRead(notificationId, userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'mark_notification_unread',
|
||||
{
|
||||
description: 'Mark a single notification as unread.',
|
||||
inputSchema: {
|
||||
notificationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ notificationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = markNotificationUnread(notificationId, userId);
|
||||
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'mark_all_notifications_read',
|
||||
{
|
||||
description: "Mark all of the current user's notifications as read.",
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async () => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const count = markAllRead(userId);
|
||||
return ok({ success: true, count });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createItem as createPackingItem, updateItem as updatePackingItem,
|
||||
deleteItem as deletePackingItem,
|
||||
reorderItems as reorderPackingItems,
|
||||
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
||||
getCategoryAssignees as getPackingCategoryAssignees,
|
||||
updateCategoryAssignees as updatePackingCategoryAssignees,
|
||||
applyTemplate, saveAsTemplate, bulkImport,
|
||||
} from '../../services/packingService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerPackingTools(server: McpServer, userId: number): void {
|
||||
// --- PACKING ---
|
||||
|
||||
server.registerTool(
|
||||
'create_packing_item',
|
||||
{
|
||||
description: 'Add an item to the packing checklist for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
||||
safeBroadcast(tripId, 'packing:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_packing_item',
|
||||
{
|
||||
description: 'Check or uncheck a packing item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
checked: z.boolean(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_packing_item',
|
||||
{
|
||||
description: 'Remove an item from the packing checklist.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deletePackingItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- PACKING (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_packing_item',
|
||||
{
|
||||
description: 'Rename a packing item or change its category.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
||||
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
// --- PACKING ADVANCED ---
|
||||
|
||||
server.registerTool(
|
||||
'reorder_packing_items',
|
||||
{
|
||||
description: 'Set the display order of packing items within a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in desired order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
reorderPackingItems(tripId, orderedIds);
|
||||
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_packing_bags',
|
||||
{
|
||||
description: 'List all packing bags for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const bags = listBags(tripId);
|
||||
return ok({ bags });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_packing_bag',
|
||||
{
|
||||
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(100),
|
||||
color: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const bag = createBag(tripId, { name, color });
|
||||
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
||||
return ok({ bag });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_packing_bag',
|
||||
{
|
||||
description: 'Rename or recolor a packing bag.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
bagId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, bagId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const fields: Record<string, unknown> = {};
|
||||
const bodyKeys: string[] = [];
|
||||
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
||||
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
||||
const bag = updateBag(tripId, bagId, fields, bodyKeys);
|
||||
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
||||
return ok({ bag });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_packing_bag',
|
||||
{
|
||||
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
bagId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, bagId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
deleteBag(tripId, bagId);
|
||||
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'set_bag_members',
|
||||
{
|
||||
description: 'Assign trip members to a packing bag (determines who packs what bag).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
bagId: z.number().int().positive(),
|
||||
userIds: z.array(z.number().int().positive()),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, bagId, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
setBagMembers(tripId, bagId, userIds);
|
||||
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_packing_category_assignees',
|
||||
{
|
||||
description: 'Get which trip members are assigned to each packing category.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignees = getPackingCategoryAssignees(tripId);
|
||||
return ok({ assignees });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'set_packing_category_assignees',
|
||||
{
|
||||
description: 'Assign trip members to a packing category.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
categoryName: z.string().min(1).max(100),
|
||||
userIds: z.array(z.number().int().positive()),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, categoryName, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'apply_packing_template',
|
||||
{
|
||||
description: 'Apply a packing template to a trip (adds items from the template).',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
templateId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, templateId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const applied = applyTemplate(tripId, templateId);
|
||||
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'save_packing_template',
|
||||
{
|
||||
description: 'Save the current packing list as a reusable template.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
templateName: z.string().min(1).max(100),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, templateName }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
saveAsTemplate(tripId, userId, templateName);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'bulk_import_packing',
|
||||
{
|
||||
description: 'Import multiple packing items at once from a list.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
items: z.array(z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().optional(),
|
||||
quantity: z.number().int().positive().optional(),
|
||||
})).min(1),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, items }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
bulkImport(tripId, items);
|
||||
safeBroadcast(tripId, 'packing:updated', {});
|
||||
return ok({ success: true, count: items.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerPlaceTools(server: McpServer, userId: number): void {
|
||||
// --- PLACES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
description: 'Update an existing place in a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
|
||||
price: z.number().optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
|
||||
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
|
||||
duration_minutes: z.number().int().positive().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:updated', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_place',
|
||||
{
|
||||
description: 'Delete a place from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deletePlace(String(tripId), String(placeId));
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_places',
|
||||
{
|
||||
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId, search, category, tag, assignment }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const places = listPlaces(String(tripId), { search, category, tag, assignment });
|
||||
return ok({ places });
|
||||
}
|
||||
);
|
||||
|
||||
// --- CATEGORIES ---
|
||||
|
||||
server.registerTool(
|
||||
'list_categories',
|
||||
{
|
||||
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const categories = listCategories();
|
||||
return ok({ categories });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SEARCH ---
|
||||
|
||||
server.registerTool(
|
||||
'search_place',
|
||||
{
|
||||
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(500).describe('Place name or address to search for'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ query }) => {
|
||||
try {
|
||||
const result = await searchPlaces(userId, query);
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { getTripSummary } from '../../services/tripService';
|
||||
import { listItems as listPackingItems } from '../../services/packingService';
|
||||
|
||||
export function registerMcpPrompts(server: McpServer, _userId: number): void {
|
||||
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);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
|
||||
}
|
||||
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
|
||||
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
|
||||
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
|
||||
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
|
||||
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
|
||||
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
|
||||
Days: ${days?.length || 0}
|
||||
Packing: ${packingStats.packed}/${packingStats.total} items packed
|
||||
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
|
||||
Reservations: ${reservations?.length || 0}
|
||||
Collab Notes: ${collabNotes?.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 } }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'packing-list',
|
||||
{
|
||||
title: 'Packing List',
|
||||
description: 'Get a formatted packing checklist for a trip',
|
||||
argsSchema: {
|
||||
tripId: z.number().int().positive().describe('Trip ID'),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const items = listPackingItems(tripId);
|
||||
if (!items.length) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
|
||||
}
|
||||
const grouped = items.reduce((acc: Record<string, any[]>, item: any) => {
|
||||
const cat = item.category || 'General';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
const lines = Object.entries(grouped).map(([cat, items]) =>
|
||||
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
|
||||
).join('\n\n');
|
||||
const { trip } = getTripSummary(tripId) || {};
|
||||
return {
|
||||
description: `Packing list for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'budget-overview',
|
||||
{
|
||||
title: 'Budget Overview',
|
||||
description: 'Get a formatted budget summary for a trip',
|
||||
argsSchema: {
|
||||
tripId: z.number().int().positive().describe('Trip ID'),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
|
||||
}
|
||||
const summary = getTripSummary(tripId);
|
||||
if (!summary) {
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
|
||||
}
|
||||
const { trip, budget } = summary;
|
||||
const currency = trip?.currency || 'EUR';
|
||||
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
|
||||
const cat = item.category || 'Uncategorized';
|
||||
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const total = Object.values(byCategory).reduce((s, v) => s + v, 0);
|
||||
const lines = Object.entries(byCategory)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
|
||||
.join('\n');
|
||||
const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
|
||||
return {
|
||||
description: `Budget overview for "${trip?.title || tripId}"`,
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, getReservation, updateReservation, deleteReservation,
|
||||
updatePositions as updateReservationPositions,
|
||||
} from '../../services/reservationService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerReservationTools(server: McpServer, userId: number): void {
|
||||
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
day_id: z.number().int().positive().optional(),
|
||||
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
// Validate that all referenced IDs belong to this trip
|
||||
if (day_id && !getDay(day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
||||
if (place_id && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
safeBroadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
|
||||
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
|
||||
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
|
||||
if (place_id != null && !placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title, type, reservation_time, location, confirmation_number, notes, status,
|
||||
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
||||
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_reservation',
|
||||
{
|
||||
description: 'Delete a reservation from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (accommodationDeleted) {
|
||||
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
||||
}
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'reorder_reservations',
|
||||
{
|
||||
description: 'Update the display order of reservations within a day.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
positions: z.array(z.object({
|
||||
id: z.number().int().positive(),
|
||||
day_plan_position: z.number().int().min(0),
|
||||
})).describe('Array of { id, day_plan_position } pairs'),
|
||||
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, positions, dayId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
updateReservationPositions(tripId, positions, dayId);
|
||||
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'link_hotel_accommodation',
|
||||
{
|
||||
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
place_id: z.number().int().positive().describe('The hotel place to link'),
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const current = getReservation(reservationId, tripId);
|
||||
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
||||
|
||||
if (!placeExists(place_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (!getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const isNewAccommodation = !current.accommodation_id;
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
place_id,
|
||||
type: current.type,
|
||||
status: current.status as string,
|
||||
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
||||
}, current);
|
||||
|
||||
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listTags, createTag, updateTag, deleteTag } from '../../services/tagService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerTagTools(server: McpServer, userId: number): void {
|
||||
// --- TAGS ---
|
||||
|
||||
server.registerTool(
|
||||
'list_tags',
|
||||
{
|
||||
description: 'List all tags belonging to the current user.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const tags = listTags(userId);
|
||||
return ok({ tags });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_tag',
|
||||
{
|
||||
description: 'Create a new tag (user-scoped label for places).',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).max(100),
|
||||
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const tag = createTag(userId, name, color);
|
||||
return ok({ tag });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_tag',
|
||||
{
|
||||
description: 'Update the name or color of an existing tag.',
|
||||
inputSchema: {
|
||||
tagId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tagId, name, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const tag = updateTag(tagId, name, color);
|
||||
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
|
||||
return ok({ tag });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_tag',
|
||||
{
|
||||
description: 'Delete a tag (removes it from all places it was attached to).',
|
||||
inputSchema: {
|
||||
tagId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tagId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
deleteTag(tagId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
listItems as listTodoItems, createItem as createTodoItem, updateItem as updateTodoItem,
|
||||
deleteItem as deleteTodoItem, reorderItems as reorderTodoItems,
|
||||
getCategoryAssignees as getTodoCategoryAssignees, updateCategoryAssignees as updateTodoCategoryAssignees,
|
||||
} from '../../services/todoService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerTodoTools(server: McpServer, userId: number): void {
|
||||
// --- TODOS ---
|
||||
|
||||
server.registerTool(
|
||||
'list_todos',
|
||||
{
|
||||
description: 'List all to-do items for a trip, ordered by position.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const items = listTodoItems(tripId);
|
||||
return ok({ items });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_todo',
|
||||
{
|
||||
description: 'Create a new to-do item for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(500).describe('To-do item name'),
|
||||
category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'),
|
||||
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'),
|
||||
description: z.string().max(2000).optional().describe('Additional description'),
|
||||
assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'),
|
||||
priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
safeBroadcast(tripId, 'todo:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_todo',
|
||||
{
|
||||
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(500).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'),
|
||||
description: z.string().max(2000).nullable().optional().describe('Set to null to clear'),
|
||||
assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'),
|
||||
priority: z.number().int().min(0).max(3).nullable().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
// Build bodyKeys to signal which nullable fields were explicitly provided
|
||||
const bodyKeys: string[] = [];
|
||||
if (due_date !== undefined) bodyKeys.push('due_date');
|
||||
if (description !== undefined) bodyKeys.push('description');
|
||||
if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id');
|
||||
if (priority !== undefined) bodyKeys.push('priority');
|
||||
const item = updateTodoItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'todo:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_todo',
|
||||
{
|
||||
description: 'Mark a to-do item as checked (done) or unchecked.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
checked: z.boolean().describe('True to mark done, false to uncheck'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'todo:updated', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_todo',
|
||||
{
|
||||
description: 'Delete a to-do item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const deleted = deleteTodoItem(tripId, itemId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'todo:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'reorder_todos',
|
||||
{
|
||||
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
reorderTodoItems(tripId, orderedIds);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_todo_category_assignees',
|
||||
{
|
||||
description: 'Get the default assignees configured per to-do category for a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignees = getTodoCategoryAssignees(tripId);
|
||||
return ok({ assignees });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'set_todo_category_assignees',
|
||||
{
|
||||
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
categoryName: z.string().min(1).max(100).describe('Category name'),
|
||||
userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, categoryName, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
|
||||
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
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 '../../services/shareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { countMessages, listPolls } from '../../services/collabService';
|
||||
import {
|
||||
listItems as listTodoItems,
|
||||
} from '../../services/todoService';
|
||||
import { listFiles } from '../../services/fileService';
|
||||
import {
|
||||
safeBroadcast, MAX_MCP_TRIP_DAYS,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerTripTools(server: McpServer, userId: number): void {
|
||||
// --- TRIPS ---
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
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(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 };
|
||||
}
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
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 trips = listTrips(userId, include_archived ? null : 0);
|
||||
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, full budget line items with totals, full packing list with checked status, reservations, collab notes, to-do items, files, and collab poll/message counts. 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);
|
||||
if (!summary) return noAccess();
|
||||
const todos = listTodoItems(tripId);
|
||||
const files = listFiles(tripId, false).map((f: any) => ({
|
||||
id: f.id,
|
||||
original_name: f.original_name,
|
||||
mime_type: f.mime_type,
|
||||
file_size: f.file_size,
|
||||
starred: !!f.starred,
|
||||
deleted: !!f.deleted_at,
|
||||
created_at: f.created_at,
|
||||
}));
|
||||
let pollCount = 0;
|
||||
if (isAddonEnabled('collab')) {
|
||||
pollCount = listPolls(tripId).length;
|
||||
}
|
||||
let messageCount = 0;
|
||||
if (isAddonEnabled('collab')) {
|
||||
messageCount = countMessages(tripId);
|
||||
}
|
||||
return ok({ ...summary, todos, files, pollCount, messageCount });
|
||||
}
|
||||
);
|
||||
|
||||
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
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 }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const link = getShareLink(String(tripId));
|
||||
return ok({ link });
|
||||
}
|
||||
);
|
||||
|
||||
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();
|
||||
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
|
||||
share_map: share_map ?? true,
|
||||
share_bookings: share_bookings ?? true,
|
||||
share_packing: share_packing ?? false,
|
||||
share_budget: share_budget ?? false,
|
||||
share_collab: share_collab ?? false,
|
||||
});
|
||||
return ok({ token, created });
|
||||
}
|
||||
);
|
||||
|
||||
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();
|
||||
deleteShareLink(String(tripId));
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser, getCurrentUser } from '../../services/authService';
|
||||
import {
|
||||
getOwnPlan, getActivePlan, getActivePlanId, getPlanData,
|
||||
updatePlan, setUserColor,
|
||||
sendInvite as sendVacayInvite, acceptInvite, declineInvite, cancelInvite, dissolvePlan,
|
||||
getAvailableUsers,
|
||||
listYears, addYear, deleteYear,
|
||||
getEntries as getVacayEntries, toggleEntry, toggleCompanyHoliday,
|
||||
getStats as getVacayStats, updateStats as updateVacayStats,
|
||||
addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar,
|
||||
getCountries as getHolidayCountries, getHolidays,
|
||||
} from '../../services/vacayService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
|
||||
export function registerVacayTools(server: McpServer, userId: number): void {
|
||||
if (isAddonEnabled('vacay')) {
|
||||
server.registerTool(
|
||||
'get_vacay_plan',
|
||||
{
|
||||
description: "Get the current user's active vacation plan (own or joined).",
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const plan = getPlanData(userId);
|
||||
return ok({ plan });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_vacay_plan',
|
||||
{
|
||||
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
|
||||
inputSchema: {
|
||||
block_weekends: z.boolean().optional(),
|
||||
holidays_enabled: z.boolean().optional(),
|
||||
holidays_region: z.string().nullable().optional(),
|
||||
company_holidays_enabled: z.boolean().optional(),
|
||||
carry_over_enabled: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'set_vacay_color',
|
||||
{
|
||||
description: "Set the current user's color in the vacation plan calendar.",
|
||||
inputSchema: {
|
||||
color: z.string().describe('Hex color e.g. #6366f1'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
setUserColor(userId, planId, color, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_available_vacay_users',
|
||||
{
|
||||
description: 'List users who can be invited to the current vacation plan.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const planId = getActivePlanId(userId);
|
||||
const users = getAvailableUsers(userId, planId);
|
||||
return ok({ users });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'send_vacay_invite',
|
||||
{
|
||||
description: 'Invite a user to join the vacation plan by their user ID.',
|
||||
inputSchema: {
|
||||
targetUserId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ targetUserId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const me = getCurrentUser(userId);
|
||||
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
|
||||
const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'accept_vacay_invite',
|
||||
{
|
||||
description: 'Accept a pending invitation to join another user\'s vacation plan.',
|
||||
inputSchema: {
|
||||
planId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ planId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = acceptInvite(userId, planId, undefined);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'decline_vacay_invite',
|
||||
{
|
||||
description: 'Decline a pending vacation plan invitation.',
|
||||
inputSchema: {
|
||||
planId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ planId }) => {
|
||||
declineInvite(userId, planId, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'cancel_vacay_invite',
|
||||
{
|
||||
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
|
||||
inputSchema: {
|
||||
targetUserId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ targetUserId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
cancelInvite(planId, targetUserId);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'dissolve_vacay_plan',
|
||||
{
|
||||
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async () => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
dissolvePlan(userId, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_vacay_years',
|
||||
{
|
||||
description: 'List calendar years tracked in the current vacation plan.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const planId = getActivePlanId(userId);
|
||||
const years = listYears(planId);
|
||||
return ok({ years });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'add_vacay_year',
|
||||
{
|
||||
description: 'Add a calendar year to the vacation plan.',
|
||||
inputSchema: {
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ year }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const years = addYear(planId, year, undefined);
|
||||
return ok({ years });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_vacay_year',
|
||||
{
|
||||
description: 'Remove a calendar year from the vacation plan.',
|
||||
inputSchema: {
|
||||
year: z.number().int(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ year }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const years = deleteYear(planId, year, undefined);
|
||||
return ok({ years });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_vacay_entries',
|
||||
{
|
||||
description: 'Get all vacation day entries for a plan and year.',
|
||||
inputSchema: {
|
||||
year: z.number().int(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ year }) => {
|
||||
const planId = getActivePlanId(userId);
|
||||
const entries = getVacayEntries(planId, String(year));
|
||||
return ok({ entries });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_vacay_entry',
|
||||
{
|
||||
description: 'Toggle a day on or off as a vacation day for the current user.',
|
||||
inputSchema: {
|
||||
date: z.string().describe('ISO date YYYY-MM-DD'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ date }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const result = toggleEntry(userId, planId, date, undefined);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'toggle_company_holiday',
|
||||
{
|
||||
description: 'Toggle a date as a company holiday for the whole plan.',
|
||||
inputSchema: {
|
||||
date: z.string(),
|
||||
note: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ date, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const result = toggleCompanyHoliday(planId, date, note, undefined);
|
||||
return ok(result);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_vacay_stats',
|
||||
{
|
||||
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
|
||||
inputSchema: {
|
||||
year: z.number().int(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ year }) => {
|
||||
const planId = getActivePlanId(userId);
|
||||
const stats = getVacayStats(planId, year);
|
||||
return ok({ stats });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_vacay_stats',
|
||||
{
|
||||
description: 'Update the vacation day allowance for a specific user and year.',
|
||||
inputSchema: {
|
||||
year: z.number().int(),
|
||||
vacationDays: z.number().int().min(0),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ year, vacationDays }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
updateVacayStats(userId, planId, year, vacationDays, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'add_holiday_calendar',
|
||||
{
|
||||
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
|
||||
inputSchema: {
|
||||
region: z.string().describe('Country/region code e.g. US, GB, DE'),
|
||||
label: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ region, label, color, sortOrder }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
|
||||
return ok({ calendar });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_holiday_calendar',
|
||||
{
|
||||
description: 'Update label or color for a holiday calendar.',
|
||||
inputSchema: {
|
||||
calendarId: z.number().int().positive(),
|
||||
label: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ calendarId, label, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
|
||||
if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true };
|
||||
return ok({ calendar: cal });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_holiday_calendar',
|
||||
{
|
||||
description: 'Remove a holiday calendar from the vacation plan.',
|
||||
inputSchema: {
|
||||
calendarId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ calendarId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const planId = getActivePlanId(userId);
|
||||
deleteHolidayCalendar(calendarId, planId, undefined);
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_holiday_countries',
|
||||
{
|
||||
description: 'List countries available for public holiday calendars.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const result = await getHolidayCountries();
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ countries: result.data });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_holidays',
|
||||
{
|
||||
description: 'List public holidays for a country and year.',
|
||||
inputSchema: {
|
||||
country: z.string().describe('ISO 3166-1 alpha-2 code'),
|
||||
year: z.number().int(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ country, year }) => {
|
||||
const result = await getHolidays(String(year), country);
|
||||
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
return ok({ holidays: result.data });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
toggleMemberPaid,
|
||||
getPerPersonSummary,
|
||||
calculateSettlement,
|
||||
reorderBudgetItems,
|
||||
reorderBudgetCategories,
|
||||
} from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
@@ -56,6 +58,38 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder/items', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderBudgetItems(tripId, orderedIds);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:reordered', { orderedIds }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/reorder/categories', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { orderedCategories } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
reorderBudgetCategories(tripId, orderedCategories);
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'budget:reordered', { orderedCategories }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updateReservation,
|
||||
deleteReservation,
|
||||
} from '../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -53,7 +54,6 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
// Auto-create budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const { createBudgetItem } = require('../services/budgetService');
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
name: title,
|
||||
category: create_budget_entry.category || type || 'Other',
|
||||
@@ -126,7 +126,6 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
if (!create_budget_entry || !create_budget_entry.total_price) {
|
||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linked) {
|
||||
const { deleteBudgetItem } = require('../services/budgetService');
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
@@ -135,7 +134,6 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
// Auto-create or update budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const { createBudgetItem, updateBudgetItem } = require('../services/budgetService');
|
||||
const itemName = title || current.title;
|
||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
|
||||
+3
-153
@@ -23,6 +23,7 @@ import {
|
||||
addMember,
|
||||
removeMember,
|
||||
exportICS,
|
||||
copyTripById,
|
||||
verifyTripAccess,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
@@ -199,160 +200,9 @@ router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
|
||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
|
||||
if (!src) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const title = req.body.title || src.title;
|
||||
|
||||
const copyTrip = db.transaction(() => {
|
||||
// 1. Create new trip
|
||||
const tripResult = db.prepare(`
|
||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
`).run(authReq.user.id, title, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
|
||||
const newTripId = tripResult.lastInsertRowid;
|
||||
|
||||
// 2. Copy days → build ID map
|
||||
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(req.params.id) as any[];
|
||||
const dayMap = new Map<number, number | bigint>();
|
||||
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
|
||||
for (const d of oldDays) {
|
||||
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
|
||||
dayMap.set(d.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 3. Copy places → build ID map
|
||||
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const placeMap = new Map<number, number | bigint>();
|
||||
const insertPlace = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPlaces) {
|
||||
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
||||
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
||||
p.website, p.phone, p.transport_mode, p.osm_id);
|
||||
placeMap.set(p.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 4. Copy place_tags
|
||||
const oldTags = db.prepare(`
|
||||
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
|
||||
`).all(req.params.id) as any[];
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
||||
for (const t of oldTags) {
|
||||
const newPlaceId = placeMap.get(t.place_id);
|
||||
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
|
||||
}
|
||||
|
||||
// 5. Copy day_assignments → build ID map
|
||||
const oldAssignments = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
|
||||
`).all(req.params.id) as any[];
|
||||
const assignmentMap = new Map<number, number | bigint>();
|
||||
const insertAssignment = db.prepare(`
|
||||
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAssignments) {
|
||||
const newDayId = dayMap.get(a.day_id);
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
if (newDayId && newPlaceId) {
|
||||
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
|
||||
a.reservation_status, a.reservation_notes, a.reservation_datetime,
|
||||
a.assignment_time, a.assignment_end_time);
|
||||
assignmentMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Copy day_accommodations → build ID map (before reservations, which reference them)
|
||||
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const accomMap = new Map<number, number | bigint>();
|
||||
const insertAccom = db.prepare(`
|
||||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAccom) {
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
const newStartDay = dayMap.get(a.start_day_id);
|
||||
const newEndDay = dayMap.get(a.end_day_id);
|
||||
if (newPlaceId && newStartDay && newEndDay) {
|
||||
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
|
||||
accomMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Copy reservations
|
||||
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertReservation = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, status, type, metadata, day_plan_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const r of oldReservations) {
|
||||
insertReservation.run(newTripId,
|
||||
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
|
||||
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
|
||||
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
|
||||
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
|
||||
r.title, r.reservation_time, r.reservation_end_time,
|
||||
r.location, r.confirmation_number, r.notes, r.status, r.type,
|
||||
r.metadata, r.day_plan_position);
|
||||
}
|
||||
|
||||
// 8. Copy budget_items (paid_by_user_id reset to null)
|
||||
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertBudget = db.prepare(`
|
||||
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const b of oldBudget) {
|
||||
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
|
||||
}
|
||||
|
||||
// 9. Copy packing_bags → build ID map
|
||||
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const bagMap = new Map<number, number | bigint>();
|
||||
const insertBag = db.prepare(`
|
||||
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const bag of oldBags) {
|
||||
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
|
||||
bagMap.set(bag.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
// 10. Copy packing_items (checked reset to 0)
|
||||
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertPacking = db.prepare(`
|
||||
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPacking) {
|
||||
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
|
||||
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
|
||||
}
|
||||
|
||||
// 11. Copy day_notes
|
||||
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(req.params.id) as any[];
|
||||
const insertNote = db.prepare(`
|
||||
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const n of oldNotes) {
|
||||
const newDayId = dayMap.get(n.day_id);
|
||||
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
|
||||
}
|
||||
|
||||
return newTripId;
|
||||
});
|
||||
|
||||
try {
|
||||
const newTripId = copyTrip();
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId: Number(newTripId), title } });
|
||||
const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title);
|
||||
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } });
|
||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
|
||||
res.status(201).json({ trip });
|
||||
} catch {
|
||||
|
||||
@@ -40,8 +40,8 @@ export const isDocker = (() => {
|
||||
|
||||
export function listUsers() {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
).all() as Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
|
||||
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
|
||||
).all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
|
||||
let onlineUserIds = new Set<number>();
|
||||
try {
|
||||
const { getOnlineUserIds } = require('../websocket');
|
||||
@@ -49,6 +49,7 @@ export function listUsers() {
|
||||
} catch { /* */ }
|
||||
return users.map(u => ({
|
||||
...u,
|
||||
avatar_url: u.avatar ? `/uploads/avatars/${u.avatar}` : null,
|
||||
created_at: utcSuffix(u.created_at),
|
||||
updated_at: utcSuffix(u.updated_at as string),
|
||||
last_login: utcSuffix(u.last_login),
|
||||
|
||||
@@ -14,12 +14,13 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
|
||||
}
|
||||
|
||||
function loadItemMembers(itemId: number | string) {
|
||||
return db.prepare(`
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, bm.paid, u.username, u.avatar
|
||||
FROM budget_item_members bm
|
||||
JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.budget_item_id = ?
|
||||
`).all(itemId) as BudgetItemMember[];
|
||||
return rows.map(m => ({ ...m, avatar_url: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -27,9 +28,12 @@ function loadItemMembers(itemId: number | string) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listBudgetItems(tripId: string | number) {
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
|
||||
).all(tripId) as BudgetItem[];
|
||||
const items = db.prepare(`
|
||||
SELECT bi.* FROM budget_items bi
|
||||
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
|
||||
WHERE bi.trip_id = ?
|
||||
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
|
||||
`).all(tripId) as BudgetItem[];
|
||||
|
||||
const itemIds = items.map(i => i.id);
|
||||
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
|
||||
@@ -63,11 +67,21 @@ export function createBudgetItem(
|
||||
).get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const cat = data.category || 'Other';
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
|
||||
}
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
data.category || 'Other',
|
||||
cat,
|
||||
data.name,
|
||||
data.total_price || 0,
|
||||
data.persons != null ? data.persons : null,
|
||||
@@ -113,6 +127,16 @@ export function updateBudgetItem(
|
||||
id,
|
||||
);
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
|
||||
if (!catExists) {
|
||||
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
|
||||
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, data.category, catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
|
||||
updated.members = loadItemMembers(id);
|
||||
return updated;
|
||||
@@ -254,3 +278,23 @@ export function calculateSettlement(tripId: string | number) {
|
||||
flows,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function reorderBudgetItems(tripId: string | number, orderedIds: number[]) {
|
||||
const update = db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
||||
db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => update.run(index, id, tripId));
|
||||
})();
|
||||
}
|
||||
|
||||
export function reorderBudgetCategories(tripId: string | number, orderedCategories: string[]) {
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
|
||||
);
|
||||
db.transaction(() => {
|
||||
orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -117,11 +117,12 @@ export function listNotes(tripId: string | number) {
|
||||
return notes.map(formatNote);
|
||||
}
|
||||
|
||||
export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string }) {
|
||||
export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }) {
|
||||
const pinned = data.pinned ? 1 : 0;
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null);
|
||||
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned);
|
||||
|
||||
const note = db.prepare(`
|
||||
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
|
||||
@@ -317,6 +318,11 @@ export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[])
|
||||
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
|
||||
}
|
||||
|
||||
export function countMessages(tripId: string | number): number {
|
||||
const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
|
||||
export function listMessages(tripId: string | number, before?: string | number) {
|
||||
const query = `
|
||||
SELECT m.*, u.username, u.avatar,
|
||||
|
||||
@@ -159,7 +159,7 @@ function createNotification(input: NotificationInput): number[] {
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export function createNotificationForRecipient(
|
||||
notification: {
|
||||
...row,
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,7 +249,12 @@ function getNotifications(
|
||||
const { total } = db.prepare(`SELECT COUNT(*) as total FROM notifications ${wherePlain}`).get(userId) as { total: number };
|
||||
const { unread_count } = db.prepare('SELECT COUNT(*) as unread_count FROM notifications WHERE recipient_id = ? AND is_read = 0').get(userId) as { unread_count: number };
|
||||
|
||||
return { notifications: rows, total, unread_count };
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
return { notifications: mapped, total, unread_count };
|
||||
}
|
||||
|
||||
function getUnreadCount(userId: number): number {
|
||||
@@ -326,9 +331,14 @@ async function respondToBoolean(
|
||||
WHERE n.id = ?
|
||||
`).get(notificationId) as NotificationRow;
|
||||
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: updated });
|
||||
const mappedUpdated = {
|
||||
...updated,
|
||||
sender_avatar: updated.sender_avatar ? `/uploads/avatars/${updated.sender_avatar}` : null,
|
||||
};
|
||||
|
||||
return { success: true, notification: updated };
|
||||
broadcastToUser(userId, { type: 'notification:updated', notification: mappedUpdated });
|
||||
|
||||
return { success: true, notification: mappedUpdated };
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -20,7 +20,7 @@ interface UnsplashSearchResponse {
|
||||
|
||||
export function listPlaces(
|
||||
tripId: string,
|
||||
filters: { search?: string; category?: string; tag?: string },
|
||||
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
|
||||
) {
|
||||
let query = `
|
||||
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
@@ -46,6 +46,14 @@ export function listPlaces(
|
||||
params.push(filters.tag);
|
||||
}
|
||||
|
||||
if (filters.assignment === 'unassigned') {
|
||||
query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
|
||||
params.push(tripId);
|
||||
} else if (filters.assignment === 'assigned') {
|
||||
query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
|
||||
params.push(tripId);
|
||||
}
|
||||
|
||||
query += ' ORDER BY p.created_at DESC';
|
||||
|
||||
const places = db.prepare(query).all(...params) as PlaceWithCategory[];
|
||||
@@ -133,7 +141,7 @@ export function updatePlace(
|
||||
category_id?: number; price?: number; currency?: string;
|
||||
place_time?: string; end_time?: string;
|
||||
duration_minutes?: number; notes?: string; image_url?: string;
|
||||
google_place_id?: string; website?: string; phone?: string;
|
||||
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
|
||||
transport_mode?: string; tags?: number[];
|
||||
},
|
||||
) {
|
||||
@@ -143,7 +151,7 @@ export function updatePlace(
|
||||
const {
|
||||
name, description, lat, lng, address, category_id, price, currency,
|
||||
place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone,
|
||||
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
|
||||
transport_mode, tags,
|
||||
} = body;
|
||||
|
||||
@@ -163,6 +171,7 @@ export function updatePlace(
|
||||
notes = ?,
|
||||
image_url = ?,
|
||||
google_place_id = ?,
|
||||
osm_id = ?,
|
||||
website = ?,
|
||||
phone = ?,
|
||||
transport_mode = COALESCE(?, transport_mode),
|
||||
@@ -183,6 +192,7 @@ export function updatePlace(
|
||||
notes !== undefined ? notes : existingPlace.notes,
|
||||
image_url !== undefined ? image_url : existingPlace.image_url,
|
||||
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
|
||||
osm_id !== undefined ? osm_id : existingPlace.osm_id,
|
||||
website !== undefined ? website : existingPlace.website,
|
||||
phone !== undefined ? phone : existingPlace.phone,
|
||||
transport_mode || null,
|
||||
|
||||
@@ -200,6 +200,10 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
|
||||
// Update or create accommodation for hotel reservations
|
||||
let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
|
||||
if (resolvedAccId) {
|
||||
const accExists = db.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(resolvedAccId);
|
||||
if (!accExists) resolvedAccId = null;
|
||||
}
|
||||
if (type === 'hotel' && create_accommodation) {
|
||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
||||
if (accPlaceId && start_day_id && end_day_id) {
|
||||
|
||||
@@ -394,6 +394,76 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
// Days with assignments and notes
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||||
for (const day of days) {
|
||||
if (!day.date) continue;
|
||||
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address as place_address,
|
||||
COALESCE(da.assignment_time, p.place_time) as effective_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as effective_end_time
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(day.id) as any[];
|
||||
|
||||
const notes = db.prepare(
|
||||
'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC'
|
||||
).all(day.id) as any[];
|
||||
|
||||
const timed = assignments.filter(a => a.effective_time);
|
||||
const untimed = assignments.filter(a => !a.effective_time);
|
||||
|
||||
// Timed assignments → individual events
|
||||
for (const a of timed) {
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(a.id, 'assign')}\r\nDTSTAMP:${now}\r\n`;
|
||||
ics += `DTSTART:${fmtDateTime(a.effective_time, day.date + 'T00:00')}\r\n`;
|
||||
if (a.effective_end_time) {
|
||||
ics += `DTEND:${fmtDateTime(a.effective_end_time, day.date + 'T00:00')}\r\n`;
|
||||
}
|
||||
ics += `SUMMARY:${esc(a.place_name)}\r\n`;
|
||||
let desc = '';
|
||||
if (a.notes) desc += a.notes;
|
||||
if (a.place_address) desc += (desc ? '\n' : '') + a.place_address;
|
||||
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
||||
if (a.place_address) ics += `LOCATION:${esc(a.place_address)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
|
||||
// Build all-day summary event if there are untimed activities or notes
|
||||
if (untimed.length > 0 || notes.length > 0) {
|
||||
const dayTitle = day.title || `Day ${day.day_number}`;
|
||||
const endNext = new Date(day.date + 'T00:00:00');
|
||||
endNext.setDate(endNext.getDate() + 1);
|
||||
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
|
||||
|
||||
ics += `BEGIN:VEVENT\r\nUID:${uid(day.id, 'day')}\r\nDTSTAMP:${now}\r\n`;
|
||||
ics += `DTSTART;VALUE=DATE:${fmtDate(day.date)}\r\nDTEND;VALUE=DATE:${endStr}\r\n`;
|
||||
ics += `SUMMARY:${esc(dayTitle)}\r\n`;
|
||||
|
||||
let desc = '';
|
||||
if (untimed.length > 0) {
|
||||
desc += untimed.map(a => {
|
||||
let line = `• ${a.place_name}`;
|
||||
if (a.place_address) line += ` (${a.place_address})`;
|
||||
if (a.notes) line += ` — ${a.notes}`;
|
||||
return line;
|
||||
}).join('\n');
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
if (desc) desc += '\n\n';
|
||||
desc += 'Notes:\n' + notes.map(n => {
|
||||
let line = n.time ? `${n.time} — ${n.text}` : `• ${n.text}`;
|
||||
return line;
|
||||
}).join('\n');
|
||||
}
|
||||
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
||||
ics += `END:VEVENT\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reservations as events
|
||||
for (const r of reservations) {
|
||||
if (!r.reservation_time) continue;
|
||||
@@ -431,6 +501,158 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
return { ics, filename: `${safeFilename}.ics` };
|
||||
}
|
||||
|
||||
// ── Copy / duplicate ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Duplicates a trip (all days, places, assignments, accommodations, reservations,
|
||||
* budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`.
|
||||
* Packing items are reset to unchecked. Budget paid status is cleared.
|
||||
* Returns the new trip's ID.
|
||||
*/
|
||||
export function copyTripById(sourceTripId: string | number, newOwnerId: number, title?: string): number {
|
||||
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(sourceTripId) as any;
|
||||
if (!src) throw new NotFoundError('Trip not found');
|
||||
|
||||
const newTitle = title || src.title;
|
||||
|
||||
const fn = db.transaction(() => {
|
||||
const tripResult = db.prepare(`
|
||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
`).run(newOwnerId, newTitle, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
|
||||
const newTripId = tripResult.lastInsertRowid;
|
||||
|
||||
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(sourceTripId) as any[];
|
||||
const dayMap = new Map<number, number | bigint>();
|
||||
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
|
||||
for (const d of oldDays) {
|
||||
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
|
||||
dayMap.set(d.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const placeMap = new Map<number, number | bigint>();
|
||||
const insertPlace = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
|
||||
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
|
||||
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPlaces) {
|
||||
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
|
||||
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
|
||||
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
|
||||
p.website, p.phone, p.transport_mode, p.osm_id);
|
||||
placeMap.set(p.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
const oldTags = db.prepare(`
|
||||
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
|
||||
`).all(sourceTripId) as any[];
|
||||
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
|
||||
for (const t of oldTags) {
|
||||
const newPlaceId = placeMap.get(t.place_id);
|
||||
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
|
||||
}
|
||||
|
||||
const oldAssignments = db.prepare(`
|
||||
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
|
||||
`).all(sourceTripId) as any[];
|
||||
const assignmentMap = new Map<number, number | bigint>();
|
||||
const insertAssignment = db.prepare(`
|
||||
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAssignments) {
|
||||
const newDayId = dayMap.get(a.day_id);
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
if (newDayId && newPlaceId) {
|
||||
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
|
||||
a.reservation_status, a.reservation_notes, a.reservation_datetime,
|
||||
a.assignment_time, a.assignment_end_time);
|
||||
assignmentMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const accomMap = new Map<number, number | bigint>();
|
||||
const insertAccom = db.prepare(`
|
||||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const a of oldAccom) {
|
||||
const newPlaceId = placeMap.get(a.place_id);
|
||||
const newStartDay = dayMap.get(a.start_day_id);
|
||||
const newEndDay = dayMap.get(a.end_day_id);
|
||||
if (newPlaceId && newStartDay && newEndDay) {
|
||||
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
|
||||
accomMap.set(a.id, r.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
|
||||
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const insertReservation = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
|
||||
location, confirmation_number, notes, status, type, metadata, day_plan_position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const r of oldReservations) {
|
||||
insertReservation.run(newTripId,
|
||||
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
|
||||
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
|
||||
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
|
||||
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
|
||||
r.title, r.reservation_time, r.reservation_end_time,
|
||||
r.location, r.confirmation_number, r.notes, r.status, r.type,
|
||||
r.metadata, r.day_plan_position);
|
||||
}
|
||||
|
||||
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const insertBudget = db.prepare(`
|
||||
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const b of oldBudget) {
|
||||
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
|
||||
}
|
||||
|
||||
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const bagMap = new Map<number, number | bigint>();
|
||||
const insertBag = db.prepare(`
|
||||
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const bag of oldBags) {
|
||||
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
|
||||
bagMap.set(bag.id, r.lastInsertRowid);
|
||||
}
|
||||
|
||||
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const insertPacking = db.prepare(`
|
||||
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const p of oldPacking) {
|
||||
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
|
||||
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
|
||||
}
|
||||
|
||||
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(sourceTripId) as any[];
|
||||
const insertNote = db.prepare(`
|
||||
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const n of oldNotes) {
|
||||
const newDayId = dayMap.get(n.day_id);
|
||||
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
|
||||
}
|
||||
|
||||
return Number(newTripId);
|
||||
});
|
||||
|
||||
return fn();
|
||||
}
|
||||
|
||||
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
|
||||
|
||||
export function getTripSummary(tripId: number) {
|
||||
@@ -448,6 +670,7 @@ export function getTripSummary(tripId: number) {
|
||||
|
||||
const budgetItems = listBudgetItems(tripId);
|
||||
const budget = {
|
||||
items: budgetItems,
|
||||
item_count: budgetItems.length,
|
||||
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
currency: trip.currency,
|
||||
@@ -455,6 +678,7 @@ export function getTripSummary(tripId: number) {
|
||||
|
||||
const packingItems = listPackingItems(tripId);
|
||||
const packing = {
|
||||
items: packingItems,
|
||||
total: packingItems.length,
|
||||
checked: (packingItems as { checked: number }[]).filter(i => i.checked).length,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
let _userSeq = 0;
|
||||
let _tripSeq = 0;
|
||||
let _categorySeq = 0;
|
||||
let _tagSeq = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users
|
||||
@@ -319,6 +321,32 @@ export function createCollabNote(
|
||||
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Todo Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TestTodoItem {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
name: string;
|
||||
checked: number;
|
||||
category: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export function createTodoItem(
|
||||
db: Database.Database,
|
||||
tripId: number,
|
||||
overrides: Partial<{ name: string; category: string; checked: number }> = {}
|
||||
): TestTodoItem {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder);
|
||||
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day Assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -579,3 +607,34 @@ export function setSynologyCredentials(
|
||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?')
|
||||
.run(url, username, encrypt_api_key(password), userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createCategory(
|
||||
db: Database.Database,
|
||||
overrides: { name?: string; color?: string; icon?: string; user_id?: number | null } = {}
|
||||
) {
|
||||
const name = overrides.name ?? `Test Category ${++_categorySeq}`;
|
||||
const color = overrides.color ?? '#6366f1';
|
||||
const icon = overrides.icon ?? '📍';
|
||||
const userId = overrides.user_id ?? null;
|
||||
const result = db.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)').run(name, color, icon, userId);
|
||||
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid) as { id: number; name: string; color: string; icon: string; user_id: number | null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createTag(
|
||||
db: Database.Database,
|
||||
userId: number,
|
||||
overrides: { name?: string; color?: string } = {}
|
||||
) {
|
||||
const name = overrides.name ?? `Test Tag ${++_tagSeq}`;
|
||||
const color = overrides.color ?? '#10b981';
|
||||
const result = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color);
|
||||
return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid) as { id: number; user_id: number; name: string; color: string };
|
||||
}
|
||||
|
||||
@@ -20,48 +20,73 @@ import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
|
||||
// Tables to clear on reset, ordered to avoid FK violations
|
||||
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
|
||||
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
|
||||
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
|
||||
const RESET_TABLES = [
|
||||
// Collab
|
||||
'file_links',
|
||||
'collab_message_reactions',
|
||||
'collab_poll_votes',
|
||||
'collab_messages',
|
||||
'collab_poll_options',
|
||||
'collab_polls',
|
||||
'collab_notes',
|
||||
// Day content
|
||||
'day_notes',
|
||||
'todo_category_assignees',
|
||||
'todo_items',
|
||||
'assignment_participants',
|
||||
'day_assignments',
|
||||
// Places
|
||||
'place_regions',
|
||||
'place_tags',
|
||||
'places',
|
||||
// Packing
|
||||
'packing_category_assignees',
|
||||
'packing_bag_members',
|
||||
'packing_bags',
|
||||
'packing_template_items',
|
||||
'packing_template_categories',
|
||||
'packing_templates',
|
||||
'packing_items',
|
||||
// Budget
|
||||
'budget_item_members',
|
||||
'budget_items',
|
||||
// Photos & files
|
||||
'trip_photos',
|
||||
'trip_album_links',
|
||||
'trip_files',
|
||||
'share_tokens',
|
||||
'photos',
|
||||
// Reservations
|
||||
'reservation_day_positions',
|
||||
'reservations',
|
||||
// Accommodations & days
|
||||
'day_accommodations',
|
||||
'place_tags',
|
||||
'places',
|
||||
'days',
|
||||
// Trip
|
||||
'share_tokens',
|
||||
'trip_members',
|
||||
'trips',
|
||||
// Vacay
|
||||
'vacay_entries',
|
||||
'vacay_company_holidays',
|
||||
'vacay_holiday_calendars',
|
||||
'vacay_plan_members',
|
||||
'vacay_user_colors',
|
||||
'vacay_user_years',
|
||||
'vacay_years',
|
||||
'vacay_plans',
|
||||
'atlas_visited_countries',
|
||||
'atlas_bucket_list',
|
||||
// Atlas
|
||||
'visited_regions',
|
||||
'visited_countries',
|
||||
'bucket_list',
|
||||
// Notifications & audit
|
||||
'notification_channel_preferences',
|
||||
'notifications',
|
||||
'audit_log',
|
||||
'user_settings',
|
||||
// User data
|
||||
'settings',
|
||||
'mcp_tokens',
|
||||
'mcp_sessions',
|
||||
'invite_tokens',
|
||||
'tags',
|
||||
'app_settings',
|
||||
@@ -130,8 +155,13 @@ export function createTestDb(): Database.Database {
|
||||
*/
|
||||
export function resetTestDb(db: Database.Database): void {
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
const existingTables = new Set(
|
||||
(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]).map(r => r.name)
|
||||
);
|
||||
for (const table of RESET_TABLES) {
|
||||
try { db.exec(`DELETE FROM "${table}"`); } catch { /* table may not exist in older schemas */ }
|
||||
if (existingTables.has(table)) {
|
||||
db.exec(`DELETE FROM "${table}"`);
|
||||
}
|
||||
}
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
seedDefaults(db);
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Admin user management', () => {
|
||||
.get('/api/admin/users')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.users.length).toBeGreaterThanOrEqual(3);
|
||||
expect(res.body.users).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ADMIN-002 — POST /admin/users creates a user', async () => {
|
||||
@@ -142,6 +142,10 @@ describe('Admin user management', () => {
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify the row is actually gone from the DB
|
||||
const deleted = testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id);
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||
@@ -187,19 +191,25 @@ describe('Permissions management', () => {
|
||||
expect(Array.isArray(res.body.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions updates permissions', async () => {
|
||||
it('ADMIN-008 — PUT /admin/permissions updates permissions and change persists', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
const currentPerms = getRes.body;
|
||||
|
||||
// Change trip_create from its default ('everybody') to 'admin'
|
||||
const res = await request(app)
|
||||
.put('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ permissions: currentPerms });
|
||||
.send({ permissions: { trip_create: 'admin' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Re-fetch and verify the change persisted
|
||||
const getRes = await request(app)
|
||||
.get('/api/admin/permissions')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
const tripCreatePerm = getRes.body.permissions.find((p: any) => p.key === 'trip_create');
|
||||
expect(tripCreatePerm).toBeDefined();
|
||||
expect(tripCreatePerm.level).toBe('admin');
|
||||
});
|
||||
|
||||
it('ADMIN-008 — PUT /admin/permissions without object returns 400', async () => {
|
||||
@@ -351,3 +361,171 @@ describe('JWT rotation', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Packing template CRUD (full)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Packing template CRUD (full)', () => {
|
||||
async function makeTemplate(admin: any) {
|
||||
const res = await request(app)
|
||||
.post('/api/admin/packing-templates')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Test Template' });
|
||||
return res.body.template;
|
||||
}
|
||||
|
||||
it('ADMIN-019 — GET /admin/packing-templates/:id returns template', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/admin/packing-templates/${template.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.template.id).toBe(template.id);
|
||||
expect(res.body.template.name).toBe('Test Template');
|
||||
});
|
||||
|
||||
it('ADMIN-019b — GET /admin/packing-templates/:id returns 404 for missing', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/packing-templates/99999')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-020 — PUT /admin/packing-templates/:id updates name', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Updated Name' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.template.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('ADMIN-021 — POST /admin/packing-templates/:id/categories adds a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.category.name).toBe('Clothing');
|
||||
});
|
||||
|
||||
it('ADMIN-021b — PUT /admin/packing-templates/:templateId/categories/:catId updates category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Apparel' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category.name).toBe('Apparel');
|
||||
});
|
||||
|
||||
it('ADMIN-021c — DELETE /admin/packing-templates/:templateId/categories/:catId removes category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Toiletries' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/packing-templates/${template.id}/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-021d — POST .../categories/:catId/items adds an item to category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item.name).toBe('T-Shirt');
|
||||
});
|
||||
|
||||
it('ADMIN-021e — PUT /admin/packing-templates/:templateId/items/:itemId updates item', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
const itemRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
const itemId = itemRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Polo Shirt' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.name).toBe('Polo Shirt');
|
||||
});
|
||||
|
||||
it('ADMIN-021f — DELETE /admin/packing-templates/:templateId/items/:itemId removes item', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const template = await makeTemplate(admin);
|
||||
const catRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Clothing' });
|
||||
const catId = catRes.body.category.id;
|
||||
const itemRes = await request(app)
|
||||
.post(`/api/admin/packing-templates/${template.id}/categories/${catId}/items`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'T-Shirt' });
|
||||
const itemId = itemRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/packing-templates/${template.id}/items/${itemId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP token management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP token management', () => {
|
||||
it('ADMIN-023 — GET /admin/mcp-tokens returns list', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/mcp-tokens')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.tokens)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -261,6 +261,12 @@ describe('Reorder assignments', () => {
|
||||
.send({ orderedIds: [a2.body.assignment.id, a1.body.assignment.id] });
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index')
|
||||
.all(day.id) as Array<{ id: number; order_index: number }>;
|
||||
expect(rows[0].id).toBe(a2.body.assignment.id);
|
||||
expect(rows[1].id).toBe(a1.body.assignment.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,6 +327,41 @@ describe('Assignment participants', () => {
|
||||
expect(getParticipants.body.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-010 — GET /assignments includes tags and participants when present', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Attach a tag to the place
|
||||
const tag = createTag(testDb, user.id, { name: 'Must See' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Create the assignment via API
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id });
|
||||
expect(create.status).toBe(201);
|
||||
const assignmentId = create.body.assignment.id;
|
||||
|
||||
// Add participants to the assignment
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/participants`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
// List assignments — should include tags (compact) and participants
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
const found = (res.body.assignments as any[]).find((a: any) => a.id === assignmentId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.place.tags).toHaveLength(1);
|
||||
expect(found.participants).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ASSIGN-009 — PUT /time updates assignment time fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
|
||||
@@ -383,3 +383,27 @@ describe('Mark/unmark region', () => {
|
||||
expect(deRegions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regions geo', () => {
|
||||
it('ATLAS-012 — GET /regions/geo without countries param returns empty FeatureCollection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions/geo')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('ATLAS-013 — GET /regions/geo?countries=DE,FR returns FeatureCollection', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions/geo?countries=DE,FR')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('type', 'FeatureCollection');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Authentication integration tests.
|
||||
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-030.
|
||||
* Covers AUTH-001 to AUTH-022, AUTH-028 to AUTH-033.
|
||||
* OIDC scenarios (AUTH-023 to AUTH-027) require a real IdP and are excluded.
|
||||
* Rate limiting scenarios (AUTH-004, AUTH-018) are at the end of this file.
|
||||
*/
|
||||
@@ -448,6 +448,67 @@ describe('Short-lived tokens', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Extended scenarios (AUTH-031 to AUTH-033)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Extended auth scenarios', () => {
|
||||
it('AUTH-031 — login succeeds with uppercased email (case-insensitive lookup)', async () => {
|
||||
const { user, password } = createUser(testDb, { email: 'alice@example.com' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'ALICE@EXAMPLE.COM', password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
});
|
||||
|
||||
it('AUTH-032 — registration with duplicate username returns 409', async () => {
|
||||
createUser(testDb, { username: 'alice' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({ username: 'alice', email: 'alice2@example.com', password: 'Str0ng!Pass' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('AUTH-033 — MFA backup code login succeeds and invalidates the used code', async () => {
|
||||
const { hashBackupCode, generateBackupCodes } = await import('../../src/services/authService');
|
||||
const { user, password } = createUserWithMfa(testDb);
|
||||
|
||||
// Generate and store backup codes on the MFA-enabled user
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?')
|
||||
.run(JSON.stringify(backupHashes), user.id);
|
||||
|
||||
// Step 1: login to get mfa_token
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(loginRes.body.mfa_required).toBe(true);
|
||||
const { mfa_token } = loginRes.body;
|
||||
|
||||
// Step 2: verify with a backup code
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token, code: backupCodes[0] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
|
||||
// Step 3: same backup code is now consumed — second login attempt fails
|
||||
const loginRes2 = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
const { mfa_token: mfa_token2 } = loginRes2.body;
|
||||
|
||||
const res2 = await request(app)
|
||||
.post('/api/auth/mfa/verify-login')
|
||||
.send({ mfa_token: mfa_token2, code: backupCodes[0] });
|
||||
expect(res2.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -478,3 +539,72 @@ describe('Rate limiting', () => {
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MCP token management (AUTH-034 to AUTH-039)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP token management', () => {
|
||||
it('AUTH-034 — GET /auth/mcp-tokens returns empty list initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('AUTH-035 — POST /auth/mcp-tokens creates a token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'my-token' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.token).toBeDefined();
|
||||
expect(typeof res.body.token.raw_token).toBe('string');
|
||||
});
|
||||
|
||||
it('AUTH-036 — POST /auth/mcp-tokens without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-037 — DELETE /auth/mcp-tokens/:id deletes the token', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'to-delete' });
|
||||
expect(createRes.status).toBe(201);
|
||||
const tokenId = createRes.body.token.id;
|
||||
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/auth/mcp-tokens/${tokenId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(delRes.body.success).toBe(true);
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/auth/mcp-tokens')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('AUTH-038 — DELETE /auth/mcp-tokens/:id returns 404 for non-existent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/mcp-tokens/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-039 — unauthenticated GET /auth/mcp-tokens returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/mcp-tokens');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,12 @@ vi.mock('../../src/services/backupService', async () => {
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
}),
|
||||
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteBackup: vi.fn().mockReturnValue(undefined),
|
||||
backupFileExists: vi.fn().mockReturnValue(false),
|
||||
backupFilePath: vi.fn().mockReturnValue('/tmp/test-backup.zip'),
|
||||
// Keep checkRateLimit from actual so rate-limit tests work correctly
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -70,6 +76,10 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -173,3 +183,257 @@ describe('Backup security', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup download', () => {
|
||||
let tmpFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a real temporary file that Express can stream back
|
||||
tmpFile = path.join(os.tmpdir(), `test-backup-${Date.now()}.zip`);
|
||||
fs.writeFileSync(tmpFile, 'fake zip content');
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.backupFilePath).mockReturnValue(tmpFile);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
});
|
||||
|
||||
it('BACKUP-INT-001 — GET /backup/download/:filename returns 200 with content-disposition', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/backup/download/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toMatch(/attachment/i);
|
||||
expect(res.headers['content-disposition']).toContain(filename);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-002 — GET /backup/download/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/not-a-valid-name.tar.gz')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid filename/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-003 — GET /backup/download/:filename returns 404 when file not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/backup/download/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore from existing backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup restore', () => {
|
||||
it('BACKUP-INT-004 — POST /backup/restore/:filename returns 200 on success', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-005 — POST /backup/restore/:filename returns 404 when backup not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/restore/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-006 — POST /backup/restore/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/restore/../../evil.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
// Express resolves path traversal → no route or invalid filename check
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-007 — POST /backup/restore/:filename returns 400 when restoreFromZip reports failure', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Invalid backup: travel.db not found',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/backup/restore/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/travel\.db not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup delete', () => {
|
||||
it('BACKUP-INT-008 — DELETE /backup/:filename returns 200 on success', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const filename = 'backup-2026-04-06T12-00-00.zip';
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(true);
|
||||
vi.mocked(backupService.deleteBackup).mockReturnValue(undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/backup/${filename}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(vi.mocked(backupService.deleteBackup)).toHaveBeenCalledWith(filename);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-009 — DELETE /backup/:filename returns 404 when not found', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.backupFileExists).mockReturnValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/backup-2026-04-06T12-00-00.zip')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-010 — DELETE /backup/:filename returns 400 for invalid filename', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/backup/not-a-backup.tar.gz')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/invalid filename/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiter on POST /create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup rate limiter', () => {
|
||||
it('BACKUP-INT-011 — POST /backup/create returns 429 after 3 requests', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
// Allow first 3 calls, then block
|
||||
let callCount = 0;
|
||||
vi.mocked(backupService.checkRateLimit).mockImplementation(() => {
|
||||
callCount++;
|
||||
return callCount <= 3;
|
||||
});
|
||||
|
||||
// First 3 succeed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
// 4th is rate-limited
|
||||
const res = await request(app)
|
||||
.post('/api/backup/create')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.error).toMatch(/too many/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload-restore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Backup upload-restore', () => {
|
||||
it('BACKUP-INT-012 — POST /backup/upload-restore with zip file returns 200', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
|
||||
|
||||
// Create a minimal fake zip buffer (just needs to pass multer's file filter)
|
||||
const fakeZipBuffer = Buffer.from('PK\x03\x04'); // ZIP magic bytes
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.attach('backup', fakeZipBuffer, { filename: 'test-restore.zip', contentType: 'application/zip' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(vi.mocked(backupService.restoreFromZip)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-INT-013 — POST /backup/upload-restore with no file returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no file/i);
|
||||
});
|
||||
|
||||
it('BACKUP-INT-014 — POST /backup/upload-restore returns 400 when restore fails', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Uploaded file is not a valid SQLite database',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const fakeZipBuffer = Buffer.from('PK\x03\x04');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/backup/upload-restore')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.attach('backup', fakeZipBuffer, { filename: 'bad-restore.zip', contentType: 'application/zip' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not a valid SQLite/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +209,35 @@ describe('Budget item members', () => {
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toBeDefined();
|
||||
|
||||
// After assigning members, list items should include them (covers loadBudgetItems member loop)
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.status).toBe(200);
|
||||
const foundItem = (listRes.body.items as any[]).find((i: any) => i.id === item.id);
|
||||
expect(foundItem).toBeDefined();
|
||||
expect(foundItem.members).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('BUDGET-005b — PUT /members with empty user_ids clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
// First assign a member
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Then clear members with empty array
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.members).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('BUDGET-005 — PUT /members with non-array user_ids returns 400', async () => {
|
||||
@@ -234,12 +263,22 @@ describe('Budget item members', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
// Toggle to paid=true
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.member).toBeDefined();
|
||||
expect(res.body.member.paid).toBe(1); // SQLite stores as integer
|
||||
|
||||
// Toggle back to paid=false
|
||||
const res2 = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: false });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.member.paid).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,36 +290,72 @@ describe('Budget summary and settlement', () => {
|
||||
it('BUDGET-007 — GET /summary/per-person returns per-person breakdown', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/summary/per-person`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.summary)).toBe(true);
|
||||
expect(res.body.summary).toHaveLength(1);
|
||||
const entry = res.body.summary[0];
|
||||
expect(entry.user_id).toBe(user.id);
|
||||
expect(typeof entry.total_paid).toBe('number');
|
||||
expect(entry.total_paid).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BUDGET-008 — GET /settlement returns settlement transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, user2.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Dinner', total_price: 60 });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, user2.id] });
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('balances');
|
||||
expect(res.body).toHaveProperty('flows');
|
||||
expect(Array.isArray(res.body.balances)).toBe(true);
|
||||
expect(Array.isArray(res.body.flows)).toBe(true);
|
||||
|
||||
const payerBalance = res.body.balances.find((b: any) => b.user_id === user.id);
|
||||
const nonPayerBalance = res.body.balances.find((b: any) => b.user_id === user2.id);
|
||||
expect(payerBalance.balance).toBeCloseTo(30);
|
||||
expect(nonPayerBalance.balance).toBeCloseTo(-30);
|
||||
|
||||
expect(res.body.flows).toHaveLength(1);
|
||||
expect(res.body.flows[0].from.user_id).toBe(user2.id);
|
||||
expect(res.body.flows[0].to.user_id).toBe(user.id);
|
||||
expect(res.body.flows[0].amount).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('BUDGET-009 — settlement with no payers returns empty transactions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Item with no members/payers assigned
|
||||
createBudgetItem(testDb, trip.id, { name: 'Train', total_price: 40 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.balances).toEqual([]);
|
||||
expect(res.body.flows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Categories integration tests — CAT-001 through CAT-009.
|
||||
* Covers GET/POST/PUT/DELETE /api/categories.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Categories', () => {
|
||||
it('CAT-001: GET /api/categories returns seeded default categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
// 10 default categories are seeded on reset
|
||||
expect(res.body.categories.length).toBeGreaterThanOrEqual(10);
|
||||
expect(res.body.categories[0]).toMatchObject({ name: expect.any(String), color: expect.any(String), icon: expect.any(String) });
|
||||
});
|
||||
|
||||
it('CAT-002: POST /api/categories - admin creates a new category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.category).toMatchObject({ name: 'Museum', color: '#7c3aed', icon: '🏛️' });
|
||||
expect(res.body.category.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('CAT-003: POST /api/categories - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Museum' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-004: POST /api/categories - missing name returns 400', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ color: '#7c3aed' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('CAT-005: PUT /api/categories/:id - admin updates a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
// First create one
|
||||
const createRes = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Old Name', color: '#aaaaaa', icon: '📌' });
|
||||
const catId = createRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'New Name', color: '#bbbbbb' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category.name).toBe('New Name');
|
||||
expect(res.body.category.color).toBe('#bbbbbb');
|
||||
// Icon unchanged
|
||||
expect(res.body.category.icon).toBe('📌');
|
||||
});
|
||||
|
||||
it('CAT-006: PUT /api/categories/:id - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Get a seeded category id
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
const res = await request(app)
|
||||
.put(`/api/categories/${cat.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Hacked' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-007: PUT /api/categories/:id - non-existent category returns 404', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/categories/99999')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Ghost' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('CAT-008: DELETE /api/categories/:id - admin deletes a category', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/categories')
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const catId = createRes.body.category.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${catId}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const gone = testDb.prepare('SELECT id FROM categories WHERE id = ?').get(catId);
|
||||
expect(gone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CAT-009: DELETE /api/categories/:id - non-admin returns 403', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number };
|
||||
const res = await request(app)
|
||||
.delete(`/api/categories/${cat.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('CAT-010: GET /api/categories - unauthenticated returns 401', async () => {
|
||||
const res = await request(app).get('/api/categories');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,15 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Partially mock collabService to make fetchLinkPreview controllable
|
||||
vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/collabService')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchLinkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: '' }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
@@ -49,6 +58,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
@@ -637,4 +647,140 @@ describe('Collab validation', () => {
|
||||
.send({ text: 'A'.repeat(5001) });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/polls`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ question: 'Only one option?', options: ['Option A'] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/2 options/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link preview', () => {
|
||||
it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/url/i);
|
||||
});
|
||||
|
||||
it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
|
||||
title: 'Example Domain',
|
||||
description: 'A test page',
|
||||
image: null,
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Example Domain');
|
||||
});
|
||||
|
||||
it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
url: 'http://127.0.0.1',
|
||||
error: 'Requests to loopback and link-local addresses are not allowed',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(collabService.fetchLinkPreview).mockRejectedValueOnce(new Error('Unexpected error'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message reactions toggle', () => {
|
||||
it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msgRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Hello!' });
|
||||
expect(msgRes.status).toBe(201);
|
||||
const messageId = msgRes.body.message.id;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
|
||||
expect(thumbsUp).toBeDefined();
|
||||
expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const msgRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ text: 'Toggle me!' });
|
||||
expect(msgRes.status).toBe(201);
|
||||
const messageId = msgRes.body.message.id;
|
||||
|
||||
// First call — adds the reaction
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
// Second call with same emoji — should toggle it off
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ emoji: '👍' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reactions).toBeDefined();
|
||||
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
|
||||
// After toggling off, either the entry is absent or the user is no longer in it
|
||||
const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
|
||||
expect(userStillReacted).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,46 @@ describe('Accommodations', () => {
|
||||
expect(reservation.confirmation_number).toBe('CONF-XYZ');
|
||||
});
|
||||
|
||||
it('ACCOM-004 — PUT /api/trips/:tripId/accommodations/:id updates the accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-10-20' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-10-22' });
|
||||
const day3 = createDay(testDb, trip.id, { date: '2026-10-25' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'City Inn' });
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/accommodations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day2.id, notes: 'Original' });
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
const accommodationId = createRes.body.accommodation.id;
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/accommodations/${accommodationId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ place_id: place.id, start_day_id: day1.id, end_day_id: day3.id, notes: 'Extended stay' });
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.body.accommodation).toBeDefined();
|
||||
expect(updateRes.body.accommodation.end_day_id).toBe(day3.id);
|
||||
expect(updateRes.body.accommodation.notes).toBe('Extended stay');
|
||||
});
|
||||
|
||||
it('ACCOM-004 — PUT non-existent accommodation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/accommodations/999999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ notes: 'Ghost update' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('ACCOM-003 — Deleting accommodation also removes the linked reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Trip' });
|
||||
|
||||
@@ -77,6 +77,7 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to upload a file and return the file object
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Basic smoke test to validate the integration test DB mock pattern.
|
||||
* Tests MISC-001 — Health check endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Create a bare in-memory DB instance via vi.hoisted() so it exists
|
||||
// before the mock factory below runs. Schema setup happens in beforeAll
|
||||
// (after mocks are registered, so config is mocked when migrations run).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: (placeId: number) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 2: Register mocks BEFORE app is imported (these are hoisted by Vitest)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 3: Import app AFTER mocks (Vitest hoisting ensures mocks are ready first)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
// Schema setup runs here — config is mocked so migrations work correctly
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Health check', () => {
|
||||
it('MISC-001 — GET /api/health returns 200 with status ok', async () => {
|
||||
const res = await request(app).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Basic auth', () => {
|
||||
it('AUTH-014 — GET /api/auth/me without session returns 401', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('AUTH_REQUIRED');
|
||||
});
|
||||
|
||||
it('AUTH-001 — POST /api/auth/login with valid credentials returns 200 + cookie', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: user.email, password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toMatchObject({ id: user.id, email: user.email });
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookies: string[] = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie']
|
||||
: [res.headers['set-cookie']];
|
||||
expect(cookies.some((c: string) => c.includes('trek_session'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTH-014 — authenticated GET /api/auth/me returns user object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.id).toBe(user.id);
|
||||
expect(res.body.user.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,18 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
|
||||
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
||||
vi.mock('../../src/services/mapsService', () => ({
|
||||
searchPlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('SSRF or invalid URL'), { status: 400 })
|
||||
),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
@@ -47,6 +59,7 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
@@ -133,3 +146,135 @@ describe('Maps SSRF protection', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maps happy paths (mocked service)', () => {
|
||||
it('MAPS-002 — POST /maps/search returns results from service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.searchPlaces).mockResolvedValueOnce({
|
||||
results: [{ address: 'Paris, France', source: 'nominatim' }],
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ query: 'Paris' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(res.body.results[0].address).toBe('Paris, France');
|
||||
});
|
||||
|
||||
it('MAPS-003 — GET /maps/details/:placeId returns place details', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlaceDetails).mockResolvedValueOnce({
|
||||
name: 'Eiffel Tower',
|
||||
address: 'Champ de Mars, Paris',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/details/ChIJLU7jZClu5kcR4PcOOO6p3I0')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-004 — GET /maps/place-photo/:placeId returns photo url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlacePhoto).mockResolvedValueOnce({
|
||||
url: 'https://example.com/photo.jpg',
|
||||
source: 'wikimedia',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/place-photo/ChIJLU7jZClu5kcR4PcOOO6p3I0?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBe('https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('MAPS-005 — GET /maps/reverse returns geocoded location', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.reverseGeocode).mockResolvedValueOnce({
|
||||
name: 'Eiffel Tower',
|
||||
address: 'Champ de Mars, Paris',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('MAPS-008 — POST /maps/resolve-url returns extracted coordinates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.resolveGoogleMapsUrl).mockResolvedValueOnce({
|
||||
lat: 48.8584,
|
||||
lng: 2.2945,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/resolve-url')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/place/eiffel-tower' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.lat).toBe(48.8584);
|
||||
expect(res.body.lng).toBe(2.2945);
|
||||
});
|
||||
|
||||
it('MAPS-002 — search service error propagates correct status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const err = Object.assign(new Error('No API key'), { status: 503 });
|
||||
vi.mocked(mapsService.searchPlaces).mockRejectedValueOnce(err);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/maps/search')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ query: 'Anywhere' });
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it('MAPS-003 — getPlaceDetails error returns 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlaceDetails).mockRejectedValueOnce(new Error('External API failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/details/some-place-id')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('MAPS-004 — getPlacePhoto error with status returns that status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.getPlacePhoto).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Photo not found'), { status: 404 })
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/place-photo/some-place-id?lat=48.8&lng=2.3')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('MAPS-005 — reverseGeocode error returns null values', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(mapsService.reverseGeocode).mockRejectedValueOnce(new Error('Geocode failed'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/maps/reverse?lat=48.8584&lng=2.2945')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.name).toBeNull();
|
||||
expect(res.body.address).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,3 +511,214 @@ describe('Immich auth checks', () => {
|
||||
expect((await request(app).get(`${IMMICH}/assets/1/asset-x/1/original`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Immich syncAlbumAssets', () => {
|
||||
it('IMMICH-080 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('immich');
|
||||
});
|
||||
|
||||
it('IMMICH-081 — POST sync when user is not a trip member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
setImmichCredentials(testDb, owner.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
// outsider is not a trip member — getAlbumIdFromLink checks canAccessTrip
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('IMMICH-082 — POST sync when Immich is not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// No Immich credentials set — but still need a valid album link owned by user
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-083 — POST sync when safeFetch throws returns 502', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new Error('network failure during sync'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('IMMICH-084 — POST sync when album link does not belong to requesting user returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
setImmichCredentials(testDb, member.id, 'https://immich.example.com', 'test-api-key');
|
||||
// Album link is owned by owner, not member
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'immich', 'album-uuid-1', 'Vacation 2024');
|
||||
|
||||
// member is a trip member but the album link belongs to owner — getAlbumIdFromLink checks user_id
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('IMMICH-085 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${IMMICH}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||
|
||||
describe('Immich searchPhotos pagination safety', () => {
|
||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Return a full page of 1000 items on every call, so the loop would
|
||||
// run indefinitely without the page > 20 safety check.
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Clear previous call history so the count only reflects this test
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
||||
expect(res.body.assets.length).toBe(20000);
|
||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ── saveImmichSettings clearing credentials ───────────────────────────────────
|
||||
|
||||
describe('Immich saveImmichSettings clearing URL', () => {
|
||||
it('IMMICH-095 — PUT /settings with no URL clears immich_url but preserves (updates) api key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
|
||||
|
||||
// Send without immich_url to trigger the else branch (clear URL path)
|
||||
const res = await request(app)
|
||||
.put(`${IMMICH}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_api_key: 'new-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
});
|
||||
|
||||
it('IMMICH-096 — PUT /settings with empty string URL clears immich_url', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'old-key');
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${IMMICH}/settings`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: '', immich_api_key: 'old-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT immich_url FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.immich_url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── testConnection canonical URL detection ────────────────────────────────────
|
||||
|
||||
describe('Immich testConnection canonical URL detection', () => {
|
||||
it('IMMICH-100 — POST /test with http URL that gets upgraded to https returns canonicalUrl', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// Mock safeFetch so the response.url reflects https upgrade
|
||||
vi.mocked(safeFetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: 'https://immich.example.com/api/users/me',
|
||||
headers: { get: (h: string) => h === 'content-type' ? 'application/json' : null } as any,
|
||||
json: async () => ({ name: 'Redirect User', email: 'redirect@immich.local' }),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'http://immich.example.com', immich_api_key: 'valid-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.canonicalUrl).toBe('https://immich.example.com');
|
||||
});
|
||||
|
||||
it('IMMICH-101 — POST /test with https URL that stays https does not return canonicalUrl', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
// The default mock returns a response without .url property — no upgrade
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/test`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ immich_url: 'https://immich.example.com', immich_api_key: 'valid-key' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(true);
|
||||
expect(res.body.canonicalUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -543,3 +543,322 @@ describe('Synology auth checks', () => {
|
||||
expect((await request(app).get(`${SYNO}/assets/1/photo-x/1/thumbnail`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Album sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
import { addAlbumLink } from '../helpers/factories';
|
||||
|
||||
describe('Synology syncSynologyAlbumLink', () => {
|
||||
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
// The migration inserts synologyphotos with enabled=0; ensure it is enabled for this test.
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
// album_id must be a numeric string so getAlbumIdFromLink returns it and
|
||||
// syncSynologyAlbumLink passes Number(album_id) to the API.
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('synologyphotos');
|
||||
});
|
||||
|
||||
it('SYNO-051 — POST sync when user is not a trip member returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
setSynologyCredentials(testDb, owner.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
const link = addAlbumLink(testDb, trip.id, owner.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(outsider.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('SYNO-052 — POST sync when Synology is not configured returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// No credentials — album link still exists for the user
|
||||
const link = addAlbumLink(testDb, trip.id, user.id, 'synologyphotos', '1', 'Summer Trip');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('SYNO-053 — POST sync without auth returns 401', async () => {
|
||||
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Session retry logic ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology session retry on error codes 106/107/119', () => {
|
||||
it('SYNO-060 — request retries with fresh session when API returns error code 119', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
// Clear previous call history so the count only reflects this test's calls
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
|
||||
// Call sequence:
|
||||
// 1. Auth login (fresh session — no cached SID) → success with sid
|
||||
// 2. SYNO.Foto.Browse.Album call → returns { success: false, error: { code: 119 } }
|
||||
// 3. Auth login again (retry session after clearing SID) → success with new sid
|
||||
// 4. SYNO.Foto.Browse.Album retry call → success
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// call 1: initial login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'first-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 2: album list → session expired (119)
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: false, error: { code: 119 } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 3: retry login after clearing SID
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'second-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
// call 4: retry album list → success
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [{ id: 99, name: 'Retry Album', item_count: 5 }],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.albums)).toBe(true);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
|
||||
// Four safeFetch calls: login, failed album list, re-login, successful album list
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-one' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: false, error: { code: 106 } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-two' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { list: [{ id: 3, name: 'Timeout Album', item_count: 2 }] },
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Date range search ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Synology searchSynologyPhotos date range', () => {
|
||||
it('SYNO-070 — POST /search with from/to passes start_time and end_time to Synology API', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
// Capture the body sent on the search call (second safeFetch call after auth)
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
// login
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
list: [
|
||||
{
|
||||
id: 201,
|
||||
filename: 'dated.jpg',
|
||||
filesize: 512000,
|
||||
time: 1717228800,
|
||||
additional: {
|
||||
thumbnail: { cache_key: '201_abc' },
|
||||
address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' },
|
||||
exif: {},
|
||||
gps: {},
|
||||
resolution: { width: 4000, height: 3000 },
|
||||
orientation: 1,
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ from: '2024-06-01', to: '2024-06-30' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
|
||||
// Verify date parameters were forwarded in the Synology API request body
|
||||
expect(capturedBody).not.toBeNull();
|
||||
const startTime = capturedBody!.get('start_time');
|
||||
const endTime = capturedBody!.get('end_time');
|
||||
expect(startTime).toBeDefined();
|
||||
expect(Number(startTime)).toBeGreaterThan(0);
|
||||
expect(endTime).toBeDefined();
|
||||
expect(Number(endTime)).toBeGreaterThan(Number(startTime));
|
||||
});
|
||||
|
||||
it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
let capturedBody: URLSearchParams | null = null;
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockImplementationOnce((_url: string, init?: any) => {
|
||||
capturedBody = init?.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init?.body ?? ''));
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { list: [] } }),
|
||||
body: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${SYNO}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody!.get('start_time')).toBeNull();
|
||||
expect(capturedBody!.get('end_time')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
|
||||
|
||||
describe('Synology SSRF blocked error handling', () => {
|
||||
it('SYNO-080 — safeFetch throwing SsrfBlockedError for private IP URL returns connected: false', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'http://192.168.1.200', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Make safeFetch throw SsrfBlockedError — simulating the SSRF guard blocking the private IP.
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400).
|
||||
// getSynologyStatus receives the failure from _getSynologySession and returns { connected: false }.
|
||||
vi.mocked(safeFetch).mockRejectedValueOnce(new SsrfErr('Private IP not allowed'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/status`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
|
||||
|
||||
// Auth succeeds, but the album-list call throws SsrfBlockedError
|
||||
vi.mocked(safeFetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
|
||||
body: null,
|
||||
} as any)
|
||||
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`${SYNO}/albums`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,24 +119,3 @@ describe('Force HTTPS redirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories endpoint', () => {
|
||||
it('MISC-013/PLACE-015 — GET /api/categories returns seeded categories', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/categories')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.categories)).toBe(true);
|
||||
expect(res.body.categories.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App config', () => {
|
||||
it('MISC-015 — GET /api/auth/app-config returns configuration', async () => {
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('allow_registration');
|
||||
expect(res.body).toHaveProperty('oidc_configured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,14 @@ vi.mock('../../src/config', () => ({
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||
return {
|
||||
...actual,
|
||||
testSmtp: vi.fn().mockResolvedValue({ success: true }),
|
||||
testWebhook: vi.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
@@ -316,6 +324,30 @@ describe('Notification test endpoints', () => {
|
||||
.send({ url: 'not-a-url' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('NOTIF-005b — admin can call test-smtp and gets a result', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-smtp')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ email: 'test@example.com' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('success');
|
||||
});
|
||||
|
||||
it('NOTIF-006c — POST /api/notifications/test-webhook with valid URL calls testWebhook', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-webhook')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://webhook.site/test-endpoint' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('success');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* OIDC integration tests — OIDC-001 through OIDC-010.
|
||||
* Covers /api/auth/oidc/login, /callback, /exchange.
|
||||
* HTTP calls (discover, exchangeCodeForToken, getUserInfo) are mocked.
|
||||
* State management, auth codes, and findOrCreateUser run against the real test DB.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
|
||||
vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/oidcService')>();
|
||||
return {
|
||||
...actual,
|
||||
discover: vi.fn(),
|
||||
exchangeCodeForToken: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
|
||||
const mockDiscover = vi.mocked(oidcService.discover);
|
||||
const mockExchangeCode = vi.mocked(oidcService.exchangeCodeForToken);
|
||||
const mockGetUserInfo = vi.mocked(oidcService.getUserInfo);
|
||||
|
||||
const MOCK_DISCOVERY_DOC = {
|
||||
authorization_endpoint: 'https://oidc.example.com/auth',
|
||||
token_endpoint: 'https://oidc.example.com/token',
|
||||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||||
};
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set OIDC environment variables for each test
|
||||
process.env.OIDC_ISSUER = 'https://oidc.example.com';
|
||||
process.env.OIDC_CLIENT_ID = 'test-client-id';
|
||||
process.env.OIDC_CLIENT_SECRET = 'test-client-secret';
|
||||
process.env.APP_URL = 'http://localhost:3001';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.APP_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── /login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/login', () => {
|
||||
it('OIDC-001: redirects to OIDC authorization endpoint (302)', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('https://oidc.example.com/auth');
|
||||
expect(res.headers.location).toContain('client_id=test-client-id');
|
||||
expect(res.headers.location).toContain('response_type=code');
|
||||
expect(res.headers.location).toContain('redirect_uri=');
|
||||
expect(res.headers.location).toContain('state=');
|
||||
});
|
||||
|
||||
it('OIDC-002: returns 400 when OIDC is not configured', async () => {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-003: includes invite token in state when provided', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
|
||||
const res = await request(app).get('/api/auth/oidc/login?invite=abc123');
|
||||
expect(res.status).toBe(302);
|
||||
// State is a hex token; the invite is embedded in pendingStates (internal)
|
||||
// We just verify the redirect happened successfully
|
||||
expect(res.headers.location).toContain('state=');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /callback ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/callback', () => {
|
||||
it('OIDC-004: valid code for existing user → redirects to frontend with oidc_code', async () => {
|
||||
const { user } = createUser(testDb, { email: 'alice@example.com' });
|
||||
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({
|
||||
access_token: 'test-access-token',
|
||||
_ok: true,
|
||||
_status: 200,
|
||||
});
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-alice-123',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
});
|
||||
|
||||
// Create a valid state token
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
});
|
||||
|
||||
it('OIDC-005: new user gets created when registration is open', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'new-token', _ok: true, _status: 200 });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-newuser-999',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
expect(newUser).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-006: invalid state → redirects with invalid_state error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback?code=abc&state=invalid-state-xyz');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=invalid_state');
|
||||
});
|
||||
|
||||
it('OIDC-007: provider error param → redirects with error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback?error=access_denied');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=access_denied');
|
||||
});
|
||||
|
||||
it('OIDC-008: missing code or state → redirects with missing_params error', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/callback');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=missing_params');
|
||||
});
|
||||
|
||||
it('OIDC-009: token exchange failure → redirects with token_failed error', async () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=token_failed');
|
||||
});
|
||||
|
||||
it('OIDC-010: registration disabled for new user → redirects with registration_disabled error', async () => {
|
||||
// Need at least one existing user so isFirstUser=false
|
||||
createUser(testDb, { email: 'existing@example.com' });
|
||||
// Disable registration
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 });
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
sub: 'sub-blocked-user',
|
||||
email: 'blocked@example.com',
|
||||
name: 'Blocked',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=registration_disabled');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /exchange ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/auth/oidc/exchange', () => {
|
||||
it('OIDC-011: valid auth code returns JWT and sets cookie', async () => {
|
||||
const fakeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.sig';
|
||||
const code = oidcService.createAuthCode(fakeToken);
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.token).toBe(fakeToken);
|
||||
expect(res.headers['set-cookie']).toBeDefined();
|
||||
const cookieHeader = Array.isArray(res.headers['set-cookie'])
|
||||
? res.headers['set-cookie'].join(';')
|
||||
: res.headers['set-cookie'];
|
||||
expect(cookieHeader).toContain('trek_session');
|
||||
});
|
||||
|
||||
it('OIDC-012: missing code returns 400', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/exchange');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-013: invalid/expired code returns 400', async () => {
|
||||
const res = await request(app).get('/api/auth/oidc/exchange?code=not-a-real-code');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-014: auth code is single-use (second use returns 400)', async () => {
|
||||
const fakeToken = 'test.token.here';
|
||||
const code = oidcService.createAuthCode(fakeToken);
|
||||
|
||||
// First use: success
|
||||
const res1 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Second use: rejected
|
||||
const res2 = await request(app).get(`/api/auth/oidc/exchange?code=${code}`);
|
||||
expect(res2.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,12 @@ describe('Reorder packing items', () => {
|
||||
.send({ orderedIds: [i2.id, i1.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, sort_order FROM packing_items WHERE trip_id = ? ORDER BY sort_order')
|
||||
.all(trip.id) as Array<{ id: number; sort_order: number }>;
|
||||
expect(rows[0].id).toBe(i2.id);
|
||||
expect(rows[1].id).toBe(i1.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -360,3 +366,120 @@ describe('Category assignees', () => {
|
||||
expect(res.body.assignees).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
it('PACK-015 — POST /apply-template/:templateId applies template items to trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Beach', ?)").run(user.id);
|
||||
const cat = testDb.prepare("INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, 'Essentials', 0)").run(tpl.lastInsertRowid);
|
||||
testDb.prepare("INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, 'Sunscreen', 0)").run(cat.lastInsertRowid);
|
||||
const templateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${templateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.items)).toBe(true);
|
||||
expect(res.body.items.length).toBeGreaterThan(0);
|
||||
expect(res.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PACK-015b — POST /apply-template/:id for empty template returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Template with no items
|
||||
const tpl = testDb.prepare("INSERT INTO packing_templates (name, created_by) VALUES ('Empty', ?)").run(user.id);
|
||||
const emptyTemplateId = tpl.lastInsertRowid;
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/apply-template/${emptyTemplateId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-016 — PUT /bags/:bagId/members sets bag members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Create a bag first
|
||||
const bagRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/bags`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Carry-on' });
|
||||
expect(bagRes.status).toBe(201);
|
||||
const bagId = bagRes.body.bag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/${bagId}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, member.id] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.members)).toBe(true);
|
||||
expect(res.body.members.length).toBe(2);
|
||||
});
|
||||
|
||||
it('PACK-016b — PUT /bags/:bagId/members for non-existent bag returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/packing/bags/999999/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id] });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Add an item so the trip has something to save
|
||||
createPackingItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'My Summer Template' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.template).toBeDefined();
|
||||
expect(res.body.template.name).toBe('My Summer Template');
|
||||
});
|
||||
|
||||
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Empty Trip Template' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,14 @@ vi.mock('../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
|
||||
return {
|
||||
...actual,
|
||||
importGoogleList: vi.fn(),
|
||||
searchPlaceImage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
@@ -50,6 +58,8 @@ import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
@@ -63,6 +73,7 @@ beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -603,3 +614,179 @@ describe('GPX Import', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GPX import — no waypoints
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPX Import — edge cases', () => {
|
||||
it('PLACE-019c — GPX with no waypoints returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Minimal valid GPX with no waypoints, tracks, or routes
|
||||
const emptyGpx = Buffer.from(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>'
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/gpx`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no waypoints/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Google Maps list import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Google Maps list import', () => {
|
||||
it('PLACE-020 — POST /import/google-list without url returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-020b — POST /import/google-list success path returns 201 with places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
|
||||
places: [{ id: 1, name: 'Mocked Place' } as any],
|
||||
listName: 'My List',
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/example' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.listName).toBe('My List');
|
||||
});
|
||||
|
||||
it('PLACE-020c — POST /import/google-list returns service error status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockResolvedValueOnce({
|
||||
error: 'Invalid list URL',
|
||||
status: 422,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/bad' });
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe('Invalid list URL');
|
||||
});
|
||||
|
||||
it('PLACE-020d — POST /import/google-list thrown exception returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
vi.mocked(placeService.importGoogleList).mockRejectedValueOnce(new Error('Network failure'));
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/google-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://maps.google.com/maps/list/broken' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Place image search
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Place image search', () => {
|
||||
it('PLACE-021 — GET /:id/image returns photos on success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
|
||||
photos: [{ url: 'https://example.com/photo.jpg' }],
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.photos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('PLACE-021b — GET /:id/image returns service error status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tower' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockResolvedValueOnce({
|
||||
error: 'No images found',
|
||||
status: 404,
|
||||
} as any);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('No images found');
|
||||
});
|
||||
|
||||
it('PLACE-021c — GET /:id/image thrown exception returns 500', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Bridge' });
|
||||
|
||||
vi.mocked(placeService.searchPlaceImage).mockRejectedValueOnce(new Error('Unsplash down'));
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/places/${place.id}/image`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Delete place permission denied
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Delete place — permission edge cases', () => {
|
||||
it('PLACE-022 — DELETE place by non-owner member when place_edit is trip_owner returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Restricted Place' });
|
||||
|
||||
// Restrict place edits to trip owner only
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_place_edit', 'trip_owner')").run();
|
||||
invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete place — not found', () => {
|
||||
it('PLACE-023 — DELETE non-existent place returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/99999`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,36 +205,6 @@ describe('Settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Keys', () => {
|
||||
it('PROFILE-011 — PUT /api/auth/me/api-keys saves keys encrypted at rest', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'my-weather-key-123' });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Key in DB should be encrypted (not plaintext)
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
it('PROFILE-011 — GET /api/auth/me does not return plaintext API keys', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/auth/me/api-keys')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'plaintext-key' });
|
||||
|
||||
const me = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
// The key should be masked or absent, never plaintext
|
||||
const body = me.body.user;
|
||||
expect(body.openweather_api_key).not.toBe('plaintext-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Account deletion', () => {
|
||||
it('PROFILE-013 — DELETE /api/auth/me removes account, subsequent login fails', async () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -187,6 +187,43 @@ describe('Update reservation', () => {
|
||||
.send({ title: 'Updated' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('RESV-010 — PUT syncs check-in/out times to linked accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-08-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-08-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Sync Hotel' });
|
||||
|
||||
// Create reservation with linked accommodation
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Hotel Booking',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Update with metadata containing check-in/out times and confirmation_number
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
metadata: { check_in_time: '15:00', check_out_time: '11:00' },
|
||||
confirmation_number: 'HTL-XYZ-999',
|
||||
});
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
// Verify accommodation was updated with check-in/out
|
||||
const accom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
|
||||
expect(accom.check_in).toBe('15:00');
|
||||
expect(accom.check_out).toBe('11:00');
|
||||
expect(accom.confirmation).toBe('HTL-XYZ-999');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -241,3 +278,178 @@ describe('Batch update positions', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Budget entry auto-create / auto-update
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Reservation budget entry integration', () => {
|
||||
it('RESV-011 — POST with create_budget_entry auto-creates a linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Flight to Paris',
|
||||
type: 'flight',
|
||||
create_budget_entry: { total_price: 250, category: 'Transport' },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, res.body.reservation.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(250);
|
||||
expect(budgetItem.name).toBe('Flight to Paris');
|
||||
});
|
||||
|
||||
it('RESV-011b — POST with create_budget_entry.total_price = 0 skips budget creation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Free Entry',
|
||||
type: 'activity',
|
||||
create_budget_entry: { total_price: 0 },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItems = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ?')
|
||||
.all(trip.id) as any[];
|
||||
expect(budgetItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('RESV-012 — PUT with create_budget_entry creates a new budget item when none exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Hotel Stay', type: 'hotel' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ create_budget_entry: { total_price: 300, category: 'Accommodation' } });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resv.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(300);
|
||||
});
|
||||
|
||||
it('RESV-013 — PUT with create_budget_entry updates existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create reservation with budget entry via POST
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Car Rental',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 100, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Update with a new price — should update the existing budget item
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ create_budget_entry: { total_price: 150, category: 'Transport' } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const items = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.all(trip.id, resvId) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].total_price).toBe(150);
|
||||
});
|
||||
|
||||
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create with budget entry
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Taxi',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 50, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Verify budget item exists
|
||||
const before = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
// Update without create_budget_entry — should delete the linked budget item
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi Updated' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reservation accommodation delete', () => {
|
||||
it('RESV-009 — DELETE reservation linked to accommodation also removes the accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-07-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-07-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel Belle' });
|
||||
|
||||
// Create a reservation via API with create_accommodation as an object
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Hotel Belle Stay',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: {
|
||||
place_id: place.id,
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
},
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
// Verify accommodation was created
|
||||
const accom = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
expect(accom).toBeDefined();
|
||||
|
||||
// Delete reservation — should also remove the accommodation
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/reservations/${reservationId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const accomAfter = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE id = ?'
|
||||
).get(accom.id);
|
||||
expect(accomAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -46,29 +48,35 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Authentication security', () => {
|
||||
it('SEC-007 — JWT in Authorization Bearer header authenticates user', async () => {
|
||||
it('SEC-007 — invalid JWT in Authorization Bearer header is rejected', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
@@ -162,12 +170,21 @@ describe('Request body size limit', () => {
|
||||
describe('File download path traversal', () => {
|
||||
it('SEC-005 — path traversal in file download is blocked', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = { id: 1 };
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const upload = await request(app)
|
||||
.post(`/api/trips/${trip.id}/files`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', FIXTURE_IMG);
|
||||
expect(upload.status).toBe(201);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/1/download`)
|
||||
.set('Authorization', `Bearer ${generateToken(user.id)}`);
|
||||
// Trip 1 does not exist after resetTestDb → 404 before any file path is evaluated
|
||||
expect(res.status).toBe(404);
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
.set(authHeader(user.id));
|
||||
// resolveFilePath strips traversal via path.basename; normalized file does not exist in uploads
|
||||
expect(res.status).not.toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Settings integration tests — SET-001 through SET-008.
|
||||
* Covers GET /api/settings, PUT /api/settings, POST /api/settings/bulk.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('SET-001: GET /api/settings returns empty object for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings).toBeDefined();
|
||||
expect(typeof res.body.settings).toBe('object');
|
||||
// New user has no custom settings
|
||||
expect(Object.keys(res.body.settings)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('SET-002: PUT /api/settings sets a key/value pair', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'dark' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.key).toBe('theme');
|
||||
expect(res.body.value).toBe('dark');
|
||||
});
|
||||
|
||||
it('SET-003: PUT /api/settings updates an existing key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'dark' });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'theme', value: 'light' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.value).toBe('light');
|
||||
|
||||
// Verify the GET reflects the updated value
|
||||
const getRes = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(getRes.body.settings.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('SET-004: POST /api/settings/bulk upserts multiple settings', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'en', compact_mode: 'true' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.updated).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('SET-005: GET /api/settings reflects previously upserted values', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: { theme: 'dark', language: 'fr' } });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.theme).toBe('dark');
|
||||
expect(res.body.settings.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('SET-006: GET /api/settings without auth returns 401', async () => {
|
||||
const res = await request(app).get('/api/settings');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('SET-007: PUT /api/settings without key returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ value: 'dark' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('SET-008: PUT /api/settings with masked value is ignored (no-op)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// First set a real value
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'webhook_url', value: 'https://example.com/hook' });
|
||||
|
||||
// Then try to "save" the masked placeholder
|
||||
const res = await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ key: 'webhook_url', value: '••••••••' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.unchanged).toBe(true);
|
||||
});
|
||||
|
||||
it('SET-009: POST /api/settings/bulk without settings object returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/settings/bulk')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ settings: null });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('SET-010: settings are user-scoped (user A cannot see user B settings)', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.put('/api/settings')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ key: 'secret_setting', value: 'user_a_secret' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/settings')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.settings.secret_setting).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -205,3 +205,83 @@ describe('Shared trip access', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — day assignments and notes', () => {
|
||||
it('SHARE-010 — shared trip with days and assignments includes place data in assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Rome Trip' });
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Colosseum', lat: 41.89, lng: 12.49 });
|
||||
createDayAssignment(testDb, day.id, place.id, { notes: 'Amazing site' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(1);
|
||||
const dayAssignments = res.body.assignments[day.id];
|
||||
expect(Array.isArray(dayAssignments)).toBe(true);
|
||||
expect(dayAssignments).toHaveLength(1);
|
||||
expect(dayAssignments[0].place.name).toBe('Colosseum');
|
||||
expect(dayAssignments[0].place.lat).toBe(41.89);
|
||||
});
|
||||
|
||||
it('SHARE-011 — shared trip with day notes includes notes in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Notes Trip' });
|
||||
const day = createDay(testDb, trip.id, { date: '2025-07-01' });
|
||||
createDayNote(testDb, day.id, trip.id, { text: 'Meet at the station' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const dayNotes = res.body.dayNotes[day.id];
|
||||
expect(Array.isArray(dayNotes)).toBe(true);
|
||||
expect(dayNotes).toHaveLength(1);
|
||||
expect(dayNotes[0].text).toBe('Meet at the station');
|
||||
});
|
||||
|
||||
it('SHARE-012 — share_collab=true includes collab messages in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, ?, 0)').run(trip.id, user.id, 'Hello team!');
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ share_collab: true });
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.collab)).toBe(true);
|
||||
expect(res.body.collab).toHaveLength(1);
|
||||
expect(res.body.collab[0].text).toBe('Hello team!');
|
||||
});
|
||||
|
||||
it('SHARE-013 — assignments empty when days have no assignments', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id, { date: '2025-08-01' });
|
||||
|
||||
const create = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
const token = create.body.token;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toHaveLength(1);
|
||||
expect(res.body.assignments).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tags integration tests — TAG-001 through TAG-010.
|
||||
* Covers GET/POST/PUT/DELETE /api/tags.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
it('TAG-001: GET /api/tags returns empty array for new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('TAG-002: POST /api/tags creates a tag with default color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Must See' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tag).toMatchObject({ name: 'Must See', user_id: user.id });
|
||||
expect(res.body.tag.id).toBeDefined();
|
||||
expect(res.body.tag.color).toBe('#10b981'); // default color
|
||||
});
|
||||
|
||||
it('TAG-003: POST /api/tags creates a tag with a custom color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Foodie', color: '#f59e0b' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tag.color).toBe('#f59e0b');
|
||||
});
|
||||
|
||||
it('TAG-004: POST /api/tags without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ color: '#ff0000' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('TAG-005: PUT /api/tags/:id updates tag name and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Old Name', color: '#aaaaaa' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'New Name', color: '#bbbbbb' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tag.name).toBe('New Name');
|
||||
expect(res.body.tag.color).toBe('#bbbbbb');
|
||||
});
|
||||
|
||||
it('TAG-006: PUT /api/tags/:id - tag belonging to another user returns 404', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Tag' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
// User B tries to update User A's tag
|
||||
const res = await request(app)
|
||||
.put(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(userB.id))
|
||||
.send({ name: 'Hijacked' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TAG-007: DELETE /api/tags/:id removes the tag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone
|
||||
const listRes = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TAG-008: DELETE /api/tags/:id - tag belonging to another user returns 404', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
const createRes = await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Tag' });
|
||||
const tagId = createRes.body.tag.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tags/${tagId}`)
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TAG-009: Tags are user-scoped — user A cannot see user B tags', async () => {
|
||||
const { user: userA } = createUser(testDb);
|
||||
const { user: userB } = createUser(testDb);
|
||||
await request(app)
|
||||
.post('/api/tags')
|
||||
.set('Cookie', authCookie(userA.id))
|
||||
.send({ name: 'User A Private Tag' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tags')
|
||||
.set('Cookie', authCookie(userB.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TAG-010: Unauthenticated request returns 401', async () => {
|
||||
const res = await request(app).get('/api/tags');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Todo integration tests — TODO-001 through TODO-012.
|
||||
* Covers all endpoints at /api/trips/:tripId/todo.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('Todo items', () => {
|
||||
it('TODO-001: GET /api/trips/:id/todo returns empty items for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('TODO-002: POST /api/trips/:id/todo creates a todo with title only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Book hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item).toMatchObject({ name: 'Book hotel', checked: 0, trip_id: trip.id });
|
||||
expect(res.body.item.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('TODO-003: POST /api/trips/:id/todo creates a todo with all optional fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.item).toMatchObject({
|
||||
name: 'Pack suitcase',
|
||||
category: 'Preparation',
|
||||
description: 'Pack everything for the trip',
|
||||
priority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('TODO-004: POST /api/trips/:id/todo - missing name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Test' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('TODO-005: PUT /api/trips/:id/todo/:todoId toggles checked status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Visit museum' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
// Toggle to checked
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ checked: 1 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.checked).toBe(1);
|
||||
|
||||
// Toggle back to unchecked
|
||||
const res2 = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ checked: 0 });
|
||||
expect(res2.status).toBe(200);
|
||||
expect(res2.body.item.checked).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-006: PUT /api/trips/:id/todo/:todoId updates category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Buy souvenirs' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ category: 'Shopping' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.item.category).toBe('Shopping');
|
||||
});
|
||||
|
||||
it('TODO-007: DELETE /api/trips/:id/todo/:todoId deletes a todo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'To Delete' });
|
||||
const itemId = createRes.body.item.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/todo/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify gone from list
|
||||
const listRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(listRes.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('TODO-008: PUT /api/trips/:id/todo/reorder reorders items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Create 3 items
|
||||
const r1 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'First' });
|
||||
const r2 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Second' });
|
||||
const r3 = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Third' });
|
||||
|
||||
const id1 = r1.body.item.id;
|
||||
const id2 = r2.body.item.id;
|
||||
const id3 = r3.body.item.id;
|
||||
|
||||
// Reverse the order
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/reorder`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ orderedIds: [id3, id2, id1] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Verify the new order in the DB
|
||||
const items = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
|
||||
expect(items[0].id).toBe(id3);
|
||||
expect(items[1].id).toBe(id2);
|
||||
expect(items[2].id).toBe(id1);
|
||||
});
|
||||
|
||||
it('TODO-009: Non-member accessing trip returns 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TODO-010: Trip member can read and create todos', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Member can read
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(member.id));
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
// Member can create
|
||||
const postRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/todo`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({ name: 'Member task' });
|
||||
expect(postRes.status).toBe(201);
|
||||
});
|
||||
|
||||
it('TODO-011: PUT /api/trips/:id/todo/:todoId - non-existent item returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/99999`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Ghost' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TODO-012: GET /api/trips/:id/todo - unauthenticated returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/todo`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Todo category assignees', () => {
|
||||
it('TODO-013: GET /api/trips/:id/todo/category-assignees returns empty object for new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo/category-assignees`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toEqual({});
|
||||
});
|
||||
|
||||
it('TODO-014: PUT /api/trips/:id/todo/category-assignees/:name sets assignees', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [owner.id, member.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assignees)).toBe(true);
|
||||
expect(res.body.assignees).toHaveLength(2);
|
||||
|
||||
// Verify via GET
|
||||
const getRes = await request(app)
|
||||
.get(`/api/trips/${trip.id}/todo/category-assignees`)
|
||||
.set('Cookie', authCookie(owner.id));
|
||||
expect(getRes.body.assignees.Shopping).toBeDefined();
|
||||
expect(getRes.body.assignees.Shopping).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TODO-015: PUT category-assignees with empty array clears assignees', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
// Set assignees
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [owner.id] });
|
||||
|
||||
// Clear them
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/todo/category-assignees/Shopping`)
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_ids: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignees).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation } from '../helpers/factories';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
@@ -291,17 +291,6 @@ describe('Get trip', () => {
|
||||
expect(res.body.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('TRIP-016 — Non-member cannot access trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: nonMember } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}`)
|
||||
.set('Cookie', authCookie(nonMember.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-017 — Member can access trip → 200', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
@@ -694,3 +683,212 @@ describe('Trip members', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip (TRIP-023, TRIP-024)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip', () => {
|
||||
it('TRIP-023 — POST /api/trips/:id/copy creates a duplicate trip with 201', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original Trip', description: 'Desc' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip).toBeDefined();
|
||||
expect(res.body.trip.id).not.toBe(trip.id);
|
||||
expect(res.body.trip.title).toBe('Original Trip');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copy accepts a custom title for the new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Source' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Custom Copy' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.trip.title).toBe('Custom Copy');
|
||||
});
|
||||
|
||||
it('TRIP-023 — copied trip belongs to the requesting user', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(member.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newTrip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(res.body.trip.id) as any;
|
||||
expect(newTrip.user_id).toBe(member.id);
|
||||
});
|
||||
|
||||
it('TRIP-024 — non-member cannot copy a trip → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(stranger.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-024 — copy of non-existent trip returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/trips/999999/copy')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ICS export (TRIP-025)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ICS export', () => {
|
||||
it('TRIP-025 — GET /api/trips/:id/export.ics returns text/calendar content', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/calendar/);
|
||||
expect(res.text).toContain('BEGIN:VCALENDAR');
|
||||
expect(res.text).toContain('END:VCALENDAR');
|
||||
});
|
||||
|
||||
it('TRIP-025 — non-member cannot export ICS → 404', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/export.ics`)
|
||||
.set('Cookie', authCookie(stranger.id));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TRIP-025 — unauthenticated export returns 401', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Trip' });
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Copy trip with full data (covers loop bodies in the copy transaction)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Copy trip with data', () => {
|
||||
it('TRIP-026 — copy preserves days, places, tags, assignments, accommodations, reservations, budget, packing, notes', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Data-Rich Trip',
|
||||
start_date: '2025-09-01',
|
||||
end_date: '2025-09-03',
|
||||
});
|
||||
|
||||
const days = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as any[];
|
||||
expect(days.length).toBe(3);
|
||||
|
||||
// Place with a tag
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tower Bridge' });
|
||||
const tag = createTag(testDb, user.id, { name: 'Landmark' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
|
||||
// Day assignment
|
||||
testDb.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, 0, ?)'
|
||||
).run(days[0].id, place.id, 'Visit in morning');
|
||||
|
||||
// Accommodation spanning days 0→1
|
||||
createDayAccommodation(testDb, trip.id, place.id, days[0].id, days[1].id);
|
||||
|
||||
// Reservation on day 0
|
||||
createReservation(testDb, trip.id, { title: 'Flight Out', type: 'flight', day_id: days[0].id });
|
||||
|
||||
// Budget item
|
||||
createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 400 });
|
||||
|
||||
// Packing item
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush' });
|
||||
|
||||
// Day note
|
||||
createDayNote(testDb, days[0].id, trip.id, { text: 'Pack early!' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/copy`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Data-Rich Trip (Copy)' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const newId = res.body.trip.id;
|
||||
expect(newId).not.toBe(trip.id);
|
||||
|
||||
// Days copied
|
||||
const newDays = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(newId) as any[];
|
||||
expect(newDays).toHaveLength(3);
|
||||
|
||||
// Place copied
|
||||
const newPlaces = testDb.prepare('SELECT * FROM places WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPlaces).toHaveLength(1);
|
||||
expect(newPlaces[0].name).toBe('Tower Bridge');
|
||||
|
||||
// Place tag copied
|
||||
const newTags = testDb.prepare(
|
||||
'SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newTags).toHaveLength(1);
|
||||
|
||||
// Assignment copied
|
||||
const newAssignments = testDb.prepare(
|
||||
'SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?'
|
||||
).all(newId) as any[];
|
||||
expect(newAssignments).toHaveLength(1);
|
||||
|
||||
// Accommodation copied
|
||||
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newAccom).toHaveLength(1);
|
||||
|
||||
// Reservation copied
|
||||
const newResv = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newResv).toHaveLength(1);
|
||||
|
||||
// Budget copied
|
||||
const newBudget = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newBudget).toHaveLength(1);
|
||||
|
||||
// Packing copied (checked reset to 0)
|
||||
const newPacking = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newPacking).toHaveLength(1);
|
||||
expect(newPacking[0].checked).toBe(0);
|
||||
|
||||
// Day note copied
|
||||
const newNotes = testDb.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(newId) as any[];
|
||||
expect(newNotes).toHaveLength(1);
|
||||
expect(newNotes[0].text).toBe('Pack early!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -303,3 +303,230 @@ describe('Vacay dissolve plan', () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holiday calendar CRUD', () => {
|
||||
it('VACAY-026 — PUT /plan/holiday-calendars/:id updates an existing calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
// Create a calendar first
|
||||
const createRes = await request(app)
|
||||
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'US', label: 'US Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = createRes.body.plan?.holiday_calendars?.at(-1)?.id
|
||||
?? (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ label: 'Updated Label', color: '#ff0000' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-027 — DELETE /plan/holiday-calendars/:id removes the calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const createRes = await request(app)
|
||||
.post('/api/addons/vacay/plan/holiday-calendars')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ region: 'FR', label: 'French Holidays' });
|
||||
expect(createRes.status).toBe(200);
|
||||
const calId = (testDb.prepare('SELECT id FROM vacay_holiday_calendars ORDER BY id DESC LIMIT 1').get() as any)?.id;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/addons/vacay/plan/holiday-calendars/${calId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-027b — DELETE /plan/holiday-calendars/:id non-existent returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/addons/vacay/plan/holiday-calendars/99999')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay invite full flow', () => {
|
||||
it('VACAY-028 — POST /invite/accept joins the invitee to the owner plan', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
// Owner creates plan
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
|
||||
// Owner invites invitee
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
// Invitee accepts
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/accept')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-029 — POST /invite/decline removes the pending invite', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/decline')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('VACAY-030 — POST /invite/cancel removes the pending invite from owner side', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/invite/cancel')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay company holidays', () => {
|
||||
it('VACAY-032 — POST /entries/company-holiday toggles a company holiday', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(user.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(user.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/company-holiday')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ date: '2025-12-25', note: 'Christmas' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('VACAY-033 — POST /entries/toggle with target_user_id not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ date: '2025-07-14', target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay stats restrictions', () => {
|
||||
it('VACAY-034 — PUT /stats/:year for user not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/stats/2025')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ vacation_days: 25, target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holidays error path', () => {
|
||||
it('VACAY-035 — GET /holidays/:year/:country returns 502 when external API fetch fails', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use an unusual country/year to avoid cache hits from other tests
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2099/ZZ')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay color restriction', () => {
|
||||
it('VACAY-036 — PUT /color with target_user_id not in plan returns 403', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/addons/vacay/color')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ color: '#ff0000', target_user_id: outsider.id });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay holidays success path', () => {
|
||||
it('VACAY-037 — GET /holidays/:year/:country returns data when fetch succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique year/country to avoid cache from other tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([{ date: '2025-05-01', name: 'Labour Day', countryCode: 'AT' }]),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/vacay/holidays/2025/AT')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vacay toggle entry for plan member', () => {
|
||||
it('VACAY-038 — POST /entries/toggle with target_user_id in plan toggles their entry', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: invitee } = createUser(testDb);
|
||||
|
||||
const planRes = await request(app).get('/api/addons/vacay/plan').set('Cookie', authCookie(owner.id));
|
||||
const planId = planRes.body.plan.id;
|
||||
await request(app).post('/api/addons/vacay/years').set('Cookie', authCookie(owner.id)).send({ year: 2025 });
|
||||
|
||||
// Invite and accept so invitee is in the plan
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ user_id: invitee.id });
|
||||
await request(app)
|
||||
.post('/api/addons/vacay/invite/accept')
|
||||
.set('Cookie', authCookie(invitee.id))
|
||||
.send({ plan_id: planId });
|
||||
|
||||
// Owner toggles an entry for the invitee (who is now in the plan)
|
||||
const res = await request(app)
|
||||
.post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', authCookie(owner.id))
|
||||
.send({ date: '2025-06-10', target_user_id: invitee.id });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,4 +153,110 @@ describe('Weather with mocked API', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
});
|
||||
|
||||
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique coords to avoid cache from previous tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 3);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 4);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 2);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
// Override mock with full detailed forecast response
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
daily: {
|
||||
time: [dateStr],
|
||||
temperature_2m_max: [24],
|
||||
temperature_2m_min: [16],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [12],
|
||||
sunrise: [`${dateStr}T06:00`],
|
||||
sunset: [`${dateStr}T21:00`],
|
||||
precipitation_probability_max: [10],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${dateStr}T12:00`],
|
||||
temperature_2m: [20],
|
||||
precipitation_probability: [5],
|
||||
precipitation: [0],
|
||||
weathercode: [1],
|
||||
windspeed_10m: [10],
|
||||
relativehumidity_2m: [55],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body.type).toBe('forecast');
|
||||
});
|
||||
|
||||
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 6);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 7);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Unit tests for MCP extra assignment/reservation tools:
|
||||
* move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// move_assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: move_assignment', () => {
|
||||
it('moves assignment to a different day and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day1.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignment).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:moved', expect.any(Object));
|
||||
// Verify the assignment was moved
|
||||
const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any;
|
||||
expect(updated.day_id).toBe(day2.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'move_assignment',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_assignment_participants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_assignment_participants', () => {
|
||||
it('returns empty participants array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.participants)).toBe(true);
|
||||
expect(data.participants).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_assignment_participants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_assignment_participants', () => {
|
||||
it('sets participants and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.participants)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:participants', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears participants', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const assignment = createDayAssignment(testDb, day.id, place.id);
|
||||
// First set
|
||||
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_assignment_participants',
|
||||
arguments: { tripId: trip.id, assignmentId: 1, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_reservations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_reservations', () => {
|
||||
it('returns success and broadcasts reservation:positions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const res1 = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
|
||||
const res2 = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_reservations',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
positions: [
|
||||
{ id: res1.id, day_plan_position: 1 },
|
||||
{ id: res2.id, day_plan_position: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:positions', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_reservations',
|
||||
arguments: { tripId: trip.id, positions: [{ id: 1, day_plan_position: 0 }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for MCP atlas expanded tools (atlas addon-gated):
|
||||
* get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited,
|
||||
* get_country_atlas_places, update_bucket_list_item.
|
||||
* Also covers resources trek://atlas/stats and trek://atlas/regions.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_atlas_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_atlas_stats', () => {
|
||||
it('returns stats object without error for empty data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_visited_regions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_visited_regions', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.regions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns regions after they have been inserted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'FR-75', 'Paris', 'FR');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.regions).toHaveLength(1);
|
||||
expect(data.regions[0].region_code).toBe('FR-75');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_region_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_region_visited', () => {
|
||||
it('inserts region and returns region object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_region_visited',
|
||||
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.region).toBeDefined();
|
||||
expect(data.region.region_code).toBe('US-CA');
|
||||
expect(data.region.region_name).toBe('California');
|
||||
expect(data.region.country_code).toBe('US');
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_region_visited',
|
||||
arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// unmark_region_visited
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: unmark_region_visited', () => {
|
||||
it('removes region and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'IT-LO' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds even when region was not marked (no-op)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'unmark_region_visited',
|
||||
arguments: { regionCode: 'XX-YY' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_country_atlas_places
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_country_atlas_places', () => {
|
||||
it('returns empty places array for a new user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_country_atlas_places',
|
||||
arguments: { countryCode: 'JP' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toBeDefined();
|
||||
expect(Array.isArray(data.places)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_bucket_list_item
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_bucket_list_item', () => {
|
||||
it('updates notes and returns item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Visit Tokyo');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, notes: 'Cherry blossom season preferred' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
expect(data.item.notes).toBe('Cherry blossom season preferred');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates name of existing item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Old Name');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, name: 'New Name' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId: 99999, notes: 'Will not work' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const r = testDb.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
|
||||
).run(user.id, 'Bucket Item');
|
||||
const itemId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_bucket_list_item',
|
||||
arguments: { itemId, notes: 'blocked' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://atlas/stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://atlas/stats', () => {
|
||||
it('returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/stats' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://atlas/regions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://atlas/regions', () => {
|
||||
it('returns regions array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns inserted regions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].region_code).toBe('ES-CT');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Unit tests for MCP budget advanced tools:
|
||||
* set_budget_item_members, toggle_budget_member_paid.
|
||||
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_budget_item_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_budget_item_members', () => {
|
||||
it('sets members and broadcasts budget:members-updated', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('empty array clears members', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item).toBeDefined();
|
||||
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_budget_item_members',
|
||||
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_budget_member_paid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_budget_member_paid', () => {
|
||||
it('flips paid flag and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
|
||||
// Add member first
|
||||
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_budget_member_paid',
|
||||
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.member).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_budget_member_paid',
|
||||
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-person resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget/per-person', () => {
|
||||
it('returns array for trip with no items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlement resource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/budget/settlement', () => {
|
||||
it('returns settlement object for trip with no items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` });
|
||||
const data = JSON.parse(result.contents[0].text as string);
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* Unit tests for MCP collab polls and chat tools (collab addon-gated):
|
||||
* list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll,
|
||||
* delete_collab_poll, list_collab_messages, send_collab_message,
|
||||
* delete_collab_message, react_collab_message.
|
||||
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
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, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_collab_polls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_collab_polls', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_collab_polls',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.polls)).toBe(true);
|
||||
expect(data.polls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_collab_polls', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_collab_poll', () => {
|
||||
it('inserts poll with votes structure and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_collab_poll',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
question: 'Where should we eat?',
|
||||
options: ['Pizza', 'Sushi', 'Tacos'],
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(data.poll.question).toBe('Where should we eat?');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_collab_poll',
|
||||
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_collab_poll',
|
||||
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vote_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: vote_collab_poll', () => {
|
||||
it('records vote and broadcasts collab:poll:voted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Create a poll directly in the DB
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'vote_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:voted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'vote_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: 1, optionIndex: 0 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// close_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: close_collab_poll', () => {
|
||||
it('sets closed flag and broadcasts collab:poll:closed', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'close_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.poll).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:closed', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent poll', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'close_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'close_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_collab_poll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_collab_poll', () => {
|
||||
it('removes poll and broadcasts collab:poll:deleted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const pollId = (testDb.prepare(
|
||||
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_poll',
|
||||
arguments: { tripId: trip.id, pollId: Number(pollId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) }));
|
||||
expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_collab_messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_collab_messages', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_collab_messages',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.messages)).toBe(true);
|
||||
expect(data.messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_collab_messages', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// send_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: send_collab_message', () => {
|
||||
it('inserts message and broadcasts collab:message:created', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Hello team!' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.message).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('sends message with replyTo when parent exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Original message') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Reply here', replyTo: Number(msgId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'send_collab_message',
|
||||
arguments: { tripId: trip.id, text: 'Hello!' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_collab_message', () => {
|
||||
it('soft-deletes message and broadcasts collab:message:deleted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId) },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when message belongs to different user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Add other as trip member
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(other.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId) },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// react_collab_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: react_collab_message', () => {
|
||||
it('toggles reaction and broadcasts collab:message:reacted', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const msgId = (testDb.prepare(
|
||||
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
|
||||
).run(trip.id, user.id, 'React to me') as any).lastInsertRowid;
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'react_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: Number(msgId), emoji: '👍' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reactions).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:reacted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent message', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'react_collab_message',
|
||||
arguments: { tripId: trip.id, messageId: 99999, emoji: '👍' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab/polls', () => {
|
||||
it('returns polls list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab/messages', () => {
|
||||
it('returns messages list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/messages` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Unit tests for MCP day and accommodation tools:
|
||||
* create_day, delete_day,
|
||||
* create_accommodation, update_accommodation, delete_accommodation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_day', () => {
|
||||
it('creates a day with a date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day',
|
||||
arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day).toBeDefined();
|
||||
expect(data.day.date).toBe('2025-06-15');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a dateless day', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.day).toBeDefined();
|
||||
expect(data.day.date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_day
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_day', () => {
|
||||
it('deletes a day and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_day',
|
||||
arguments: { tripId: trip.id, dayId: day.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', { id: day.id });
|
||||
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_accommodation', () => {
|
||||
it('creates an accommodation and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel du Louvre' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-06-15' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-06-17' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
place_id: place.id,
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'CONF123',
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_accommodation',
|
||||
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_accommodation', () => {
|
||||
it('updates accommodation fields and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: acc.id, confirmation: 'NEW-CONF', check_in: '14:00' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.accommodation).toBeDefined();
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for non-existent accommodation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: 99999, confirmation: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: 1, confirmation: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_accommodation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_accommodation', () => {
|
||||
it('deletes accommodation and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
const day1 = createDay(testDb, trip.id);
|
||||
const day2 = createDay(testDb, trip.id);
|
||||
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_accommodation',
|
||||
arguments: { tripId: trip.id, accommodationId: acc.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
|
||||
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Unit tests for MCP notification tools:
|
||||
* list_notifications, get_unread_notification_count, mark_notification_read,
|
||||
* mark_notification_unread, mark_all_notifications_read, delete_notification,
|
||||
* delete_all_notifications.
|
||||
* Also covers the resource trek://notifications/in-app.
|
||||
*/
|
||||
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 } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: insert a notification directly into the DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createNotification(db: any, userId: number, overrides: any = {}) {
|
||||
const r = db.prepare(
|
||||
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)`
|
||||
).run(
|
||||
overrides.type ?? 'simple',
|
||||
overrides.scope ?? 'user',
|
||||
overrides.target ?? 0,
|
||||
userId,
|
||||
overrides.title_key ?? 'notification.test.title',
|
||||
overrides.text_key ?? 'notification.test.body'
|
||||
);
|
||||
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
|
||||
}
|
||||
|
||||
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(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_notifications', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns notifications when they exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id, { title_key: 'notif.first' });
|
||||
createNotification(testDb, user.id, { title_key: 'notif.second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only unread notifications when unread_only is true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
const read = createNotification(testDb, user.id) as any;
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_unread_notification_count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_unread_notification_count', () => {
|
||||
it('returns 0 initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 1 after inserting one unread notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_notification_read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_notification_read', () => {
|
||||
it('flips is_read to 1 and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
|
||||
expect(row.is_read).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_read',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_notification_unread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_notification_unread', () => {
|
||||
it('flips is_read to 0', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const notif = createNotification(testDb, user.id) as any;
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_unread',
|
||||
arguments: { notificationId: notif.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
|
||||
expect(row.is_read).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'mark_notification_unread',
|
||||
arguments: { notificationId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mark_all_notifications_read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: mark_all_notifications_read', () => {
|
||||
it('marks all notifications read and returns count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, user.id);
|
||||
createNotification(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.count).toBe(3);
|
||||
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
|
||||
expect(unread).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns count 0 when nothing to mark', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resource: trek://notifications/in-app
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://notifications/in-app', () => {
|
||||
it('returns notifications list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createNotification(testDb, user.id, { title_key: 'notif.test' });
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.notifications).toBeDefined();
|
||||
expect(Array.isArray(data.notifications)).toBe(true);
|
||||
expect(data.notifications).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty notifications for user with none', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.notifications).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Unit tests for MCP packing advanced tools:
|
||||
* reorder_packing_items, list_packing_bags, create_packing_bag, update_packing_bag,
|
||||
* delete_packing_bag, set_bag_members, get_packing_category_assignees,
|
||||
* set_packing_category_assignees, apply_packing_template, save_packing_template,
|
||||
* bulk_import_packing.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_packing_items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_packing_items', () => {
|
||||
it('reorders packing items and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item1 = createPackingItem(testDb, trip.id, { name: 'Shirt' });
|
||||
const item2 = createPackingItem(testDb, trip.id, { name: 'Pants' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_packing_items',
|
||||
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:reordered', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createPackingItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_packing_items',
|
||||
arguments: { tripId: trip.id, orderedIds: [item.id] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_packing_bags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_packing_bags', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_packing_bags',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns bags that exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'list_packing_bags',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bags).toHaveLength(1);
|
||||
expect(data.bags[0].name).toBe('Carry-on');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_packing_bag', () => {
|
||||
it('creates a bag and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Checked bag', color: '#3b82f6' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('Checked bag');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Bag' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_packing_bag',
|
||||
arguments: { tripId: trip.id, name: 'Bag' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_packing_bag', () => {
|
||||
it('updates bag name and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
|
||||
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: bag.id, name: 'New Name' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.bag).toBeDefined();
|
||||
expect(data.bag.name).toBe('New Name');
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: 1, name: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_packing_bag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_packing_bag', () => {
|
||||
it('deletes a bag and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.any(Object));
|
||||
expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_packing_bag',
|
||||
arguments: { tripId: trip.id, bagId: 1 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_bag_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_bag_members', () => {
|
||||
it('sets bag members and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_bag_members',
|
||||
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears bag members when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
|
||||
const bagId = r.lastInsertRowid as number;
|
||||
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_bag_members',
|
||||
arguments: { tripId: trip.id, bagId, userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_packing_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_packing_category_assignees', () => {
|
||||
it('returns empty object initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_packing_category_assignees',
|
||||
arguments: { tripId: trip.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_packing_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_packing_category_assignees', () => {
|
||||
it('sets category assignees and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears assignees when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_packing_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Electronics', userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_packing_template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: apply_packing_template', () => {
|
||||
it('returns error for non-existent template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'apply_packing_template',
|
||||
arguments: { tripId: trip.id, templateId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// save_packing_template
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: save_packing_template', () => {
|
||||
it('saves the current packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'save_packing_template',
|
||||
arguments: { tripId: trip.id, templateName: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulk_import_packing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: bulk_import_packing', () => {
|
||||
it('imports multiple packing items and count matches', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const items = [
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
{ name: 'Charger', category: 'Electronics' },
|
||||
{ name: 'Sunscreen', category: 'Toiletries' },
|
||||
];
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.count).toBe(items.length);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'bulk_import_packing',
|
||||
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlaces
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace } from '../../helpers/factories';
|
||||
import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -321,3 +321,96 @@ describe('Tool: search_place', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_places
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_places', () => {
|
||||
it('returns all places by default', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only unassigned places with assignment=unassigned', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
|
||||
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].name).toBe('Orphan Place');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only assigned places with assignment=assigned', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
|
||||
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].name).toBe('Assigned Place');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when all places are assigned', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Only Place' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('composes with search filter', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' });
|
||||
const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
const day = createDay(testDb, trip.id);
|
||||
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.places).toHaveLength(1);
|
||||
expect(data.places[0].name).toBe('Louvre Museum');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Unit tests for MCP tag, maps extras, and weather tools:
|
||||
* list_tags, create_tag, update_tag, delete_tag,
|
||||
* get_place_details, reverse_geocode, resolve_maps_url,
|
||||
* get_weather, get_detailed_weather.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/mapsService', () => ({
|
||||
searchPlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }),
|
||||
reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }),
|
||||
resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/services/weatherService', () => ({
|
||||
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
|
||||
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import * as mapsService from '../../../src/services/mapsService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_tags', () => {
|
||||
it('returns empty array initially', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only tags belonging to the current user', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'My Tag', '#ff0000');
|
||||
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(other.id, 'Other Tag', '#00ff00');
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tags).toHaveLength(1);
|
||||
expect(data.tags[0].name).toBe('My Tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_tag', () => {
|
||||
it('creates a tag and returns the tag object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Adventure', color: '#ff5500' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag).toBeDefined();
|
||||
expect(data.tag.name).toBe('Adventure');
|
||||
expect(data.tag.color).toBe('#ff5500');
|
||||
expect(data.tag.user_id).toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a tag with only a name', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Food' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag.name).toBe('Food');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_tag',
|
||||
arguments: { name: 'Blocked' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_tag', () => {
|
||||
it('updates tag name and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_tag',
|
||||
arguments: { tagId, name: 'New Name', color: '#bbbbbb' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.tag).toBeDefined();
|
||||
expect(data.tag.name).toBe('New Name');
|
||||
expect(data.tag.color).toBe('#bbbbbb');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent tagId', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_tag',
|
||||
arguments: { tagId: 99999, name: 'X' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_tag', () => {
|
||||
it('removes the tag row', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_tag',
|
||||
arguments: { tagId },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_place_details
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_place_details', () => {
|
||||
it('returns details from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.details).toBeDefined();
|
||||
expect(data.details.name).toBe('Eiffel Tower');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError when service returns null', async () => {
|
||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||
(getPlaceDetails as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_place_details',
|
||||
arguments: { placeId: 'nonexistent-place-id' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reverse_geocode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reverse_geocode', () => {
|
||||
it('returns result from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reverse_geocode',
|
||||
arguments: { lat: 48.8566, lng: 2.3522 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.name).toBe('Paris');
|
||||
expect(data.address).toBe('France');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolve_maps_url
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: resolve_maps_url', () => {
|
||||
it('returns result from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'resolve_maps_url',
|
||||
arguments: { url: 'https://maps.app.goo.gl/example' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.lat).toBe(48.8566);
|
||||
expect(data.lng).toBe(2.3522);
|
||||
expect(data.name).toBe('Paris');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_weather
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_weather', () => {
|
||||
it('returns weather from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_weather',
|
||||
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.weather).toBeDefined();
|
||||
expect(data.weather.temp).toBe(20);
|
||||
expect(data.weather.condition).toBe('sunny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_detailed_weather
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_detailed_weather', () => {
|
||||
it('returns detailed weather from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'get_detailed_weather',
|
||||
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.weather).toBeDefined();
|
||||
expect(Array.isArray(data.weather.hourly)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Unit tests for MCP todo tools:
|
||||
* create_todo, update_todo, toggle_todo, delete_todo, reorder_todos,
|
||||
* list_todos, get_todo_category_assignees, set_todo_category_assignees.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_todos', () => {
|
||||
it('returns empty list for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns todos ordered by sort_order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createTodoItem(testDb, trip.id, { name: 'First' });
|
||||
createTodoItem(testDb, trip.id, { name: 'Second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.items).toHaveLength(2);
|
||||
expect(data.items[0].name).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_todo', () => {
|
||||
it('creates a todo item with all fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_todo',
|
||||
arguments: {
|
||||
tripId: trip.id,
|
||||
name: 'Book hotel',
|
||||
category: 'Booking',
|
||||
due_date: '2025-06-01',
|
||||
description: 'Find a good deal',
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Book hotel');
|
||||
expect(data.item.category).toBe('Booking');
|
||||
expect(data.item.due_date).toBe('2025-06-01');
|
||||
expect(data.item.priority).toBe(2);
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a minimal todo item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_todo',
|
||||
arguments: { tripId: trip.id, name: 'Pack bags' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Pack bags');
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:created event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'Test' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:created', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_todo', () => {
|
||||
it('updates todo name and category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id, { name: 'Old name', category: 'General' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, name: 'New name', category: 'Booking' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('New name');
|
||||
expect(data.item.category).toBe('Booking');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears due_date when passed null', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
|
||||
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, due_date: null },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.due_date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item not found', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_todo', () => {
|
||||
it('marks a todo as done', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('unchecks a done todo', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id, { checked: 1 });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_todo',
|
||||
arguments: { tripId: trip.id, itemId: item.id, checked: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.checked).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:updated event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item not found', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_todo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_todo', () => {
|
||||
it('deletes an existing todo item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts todo:deleted event', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:deleted', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error for item not found', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: 99999 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
const item = createTodoItem(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reorder_todos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: reorder_todos', () => {
|
||||
it('reorders todo items', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item1 = createTodoItem(testDb, trip.id, { name: 'First' });
|
||||
const item2 = createTodoItem(testDb, trip.id, { name: 'Second' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'reorder_todos',
|
||||
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
// item2 should now have sort_order 0
|
||||
const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any;
|
||||
expect(updated.sort_order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_todo_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_todo_category_assignees', () => {
|
||||
it('returns empty object for a new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_todo_category_assignees', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_todo_category_assignees
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_todo_category_assignees', () => {
|
||||
it('sets category assignees and broadcasts', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [user.id] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.assignees)).toBe(true);
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:assignees', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('clears assignees when passed empty array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Set then clear
|
||||
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [] },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.assignees).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'set_todo_category_assignees',
|
||||
arguments: { tripId: trip.id, categoryName: 'Test', userIds: [] },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Unit tests for MCP trip member, copy, ICS, and share-link tools:
|
||||
* list_trip_members, add_trip_member, remove_trip_member,
|
||||
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_trip_members
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_trip_members', () => {
|
||||
it('returns owner and empty members list for own trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.owner.id).toBe(user.id);
|
||||
expect(data.owner.role).toBe('owner');
|
||||
expect(Array.isArray(data.members)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_trip_member
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_trip_member', () => {
|
||||
it('adds a member by username', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: member.username },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.member.username).toBe(member.username);
|
||||
expect(data.member.role).toBe('member');
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts member:added event', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: member.email },
|
||||
});
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when user not found', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when non-owner tries to add', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: outsider } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: outsider.username },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_trip_member',
|
||||
arguments: { tripId: trip.id, identifier: 'someone@example.com' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// remove_trip_member
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: remove_trip_member', () => {
|
||||
it('removes a member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'remove_trip_member',
|
||||
arguments: { tripId: trip.id, memberId: member.id },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('broadcasts member:removed event', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(owner.id, async (h) => {
|
||||
await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } });
|
||||
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when non-owner tries to remove', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
await withHarness(member.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'remove_trip_member',
|
||||
arguments: { tripId: trip.id, memberId: owner.id },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// copy_trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: copy_trip', () => {
|
||||
it('duplicates a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.trip).toBeTruthy();
|
||||
// New trip should be a different row
|
||||
const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any;
|
||||
expect(count.cnt).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom title when provided', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Original' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
|
||||
const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any;
|
||||
expect(newTrip).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export_trip_ics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: export_trip_ics', () => {
|
||||
it('returns ICS content for a trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.ics).toContain('BEGIN:VCALENDAR');
|
||||
expect(data.ics).toContain('Paris Trip');
|
||||
expect(data.filename).toMatch(/\.ics$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for non-member', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_share_link / create_share_link / delete_share_link
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_share_link', () => {
|
||||
it('returns null when no share link exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.link).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns share link info when it exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
// Create a share link directly
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'test-token-123', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.link.token).toBe('test-token-123');
|
||||
expect(data.link.share_map).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: create_share_link', () => {
|
||||
it('creates a new share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_share_link',
|
||||
arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.token).toBeTruthy();
|
||||
expect(data.created).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates existing share link permissions', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'existing-token', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_share_link',
|
||||
arguments: { tripId: trip.id, share_packing: true },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.created).toBe(false); // updated, not created
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: delete_share_link', () => {
|
||||
it('revokes the share link', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
|
||||
).run(trip.id, 'to-delete', user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -337,4 +337,18 @@ describe('Tool: get_trip_summary', () => {
|
||||
expect(data.trip.title).toBe('Demo Trip');
|
||||
});
|
||||
});
|
||||
|
||||
it('includes todos, files, pollCount, messageCount in response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Summary Test' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.todos)).toBe(true);
|
||||
expect(Array.isArray(data.files)).toBe(true);
|
||||
expect(typeof data.pollCount).toBe('number');
|
||||
expect(typeof data.messageCount).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* Unit tests for MCP vacay tools (vacay addon-gated):
|
||||
* get_vacay_plan, update_vacay_plan, set_vacay_color,
|
||||
* list_vacay_years, add_vacay_year, delete_vacay_year,
|
||||
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
|
||||
* get_vacay_stats, update_vacay_stats,
|
||||
* add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar,
|
||||
* list_holiday_countries, list_holidays.
|
||||
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||
|
||||
vi.mock('../../../src/services/adminService', () => ({
|
||||
isAddonEnabled: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock async service functions that make external calls
|
||||
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
|
||||
const original = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...original,
|
||||
updatePlan: vi.fn().mockResolvedValue(undefined),
|
||||
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
|
||||
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
|
||||
};
|
||||
});
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
broadcastMock.mockClear();
|
||||
delete process.env.DEMO_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: false });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
||||
const h = await createMcpHarness({ userId, withResources: true });
|
||||
try { await fn(h); } finally { await h.cleanup(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_plan', () => {
|
||||
it('returns plan data object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.plan).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_vacay_plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_vacay_plan', () => {
|
||||
it('calls updatePlan and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_vacay_plan',
|
||||
arguments: { block_weekends: true, holidays_enabled: false },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_vacay_color
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: set_vacay_color', () => {
|
||||
it('updates color and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_vacay_years
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_vacay_years', () => {
|
||||
it('returns years array', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_vacay_year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_vacay_year', () => {
|
||||
it('adds year to list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
expect(data.years).toContain(2025);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_vacay_year
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_vacay_year', () => {
|
||||
it('removes year from list', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
// Add year first
|
||||
await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
|
||||
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(Array.isArray(data.years)).toBe(true);
|
||||
expect(data.years).not.toContain(2025);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_entries', () => {
|
||||
it('returns entries array (empty initially)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.entries).toBeDefined();
|
||||
expect(Array.isArray(data.entries.entries)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_vacay_entry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_vacay_entry', () => {
|
||||
it('toggles entry and returns action', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.action).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toggle_company_holiday
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: toggle_company_holiday', () => {
|
||||
it('toggles company holiday and returns action', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'toggle_company_holiday',
|
||||
arguments: { date: '2025-12-25', note: 'Christmas' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.action).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_vacay_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: get_vacay_stats', () => {
|
||||
it('returns stats object', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.stats).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_vacay_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_vacay_stats', () => {
|
||||
it('updates vacation days allowance and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// add_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: add_holiday_calendar', () => {
|
||||
it('inserts calendar row and returns calendar', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.calendar).toBeDefined();
|
||||
expect(data.calendar.region).toBe('US');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: update_holiday_calendar', () => {
|
||||
it('updates label and color', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
// First add a calendar
|
||||
const addResult = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'DE', label: 'Germany' },
|
||||
});
|
||||
const added = parseToolResult(addResult) as any;
|
||||
const calId = added.calendar.id;
|
||||
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_holiday_calendar',
|
||||
arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.calendar).toBeDefined();
|
||||
expect(data.calendar.label).toBe('German Holidays');
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// delete_holiday_calendar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: delete_holiday_calendar', () => {
|
||||
it('removes calendar and returns success', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const addResult = await h.client.callTool({
|
||||
name: 'add_holiday_calendar',
|
||||
arguments: { region: 'FR' },
|
||||
});
|
||||
const added = parseToolResult(addResult) as any;
|
||||
const calId = added.calendar.id;
|
||||
|
||||
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_holiday_countries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_holiday_countries', () => {
|
||||
it('returns countries from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.countries).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_holidays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: list_holidays', () => {
|
||||
it('returns holidays from mocked service', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } });
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.holidays).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Resource: trek://vacay/plan', () => {
|
||||
it('returns plan data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://vacay/plan' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://vacay/entries/{year}', () => {
|
||||
it('returns entries for a year', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withResourceHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data).toBeDefined();
|
||||
expect(Array.isArray(data.entries)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||
db: { prepare: vi.fn(() => ({ get: vi.fn(), all: vi.fn() })) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
||||
import { db } from '../../../src/db/database';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
function makeReq(overrides: {
|
||||
@@ -82,6 +84,56 @@ describe('authenticate', () => {
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-003: calls next() and sets req.user for a valid JWT', () => {
|
||||
const mockUser = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user' };
|
||||
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => mockUser), all: vi.fn() } as any);
|
||||
|
||||
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
const req = makeReq({ cookies: { trek_session: token } });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect((req as any).user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('AUTH-MW-004: returns 401 for a valid JWT when user does not exist in DB', () => {
|
||||
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => undefined), all: vi.fn() } as any);
|
||||
|
||||
const token = jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
|
||||
authenticate(makeReq({ cookies: { trek_session: token } }), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-005: returns 401 for an expired JWT', () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
'test-secret',
|
||||
{ algorithm: 'HS256' }
|
||||
);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
authenticate(makeReq({ cookies: { trek_session: expiredToken } }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-006: returns 401 for a JWT signed with the wrong secret', () => {
|
||||
const tamperedToken = jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
authenticate(makeReq({ cookies: { trek_session: tamperedToken } }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminOnly ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unit tests for requireTripAccess and requireTripOwner middleware.
|
||||
* TRIP-ACCESS-001 through TRIP-ACCESS-010.
|
||||
* canAccessTrip and isOwner are mocked; no DB required.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const mockCanAccessTrip = vi.fn();
|
||||
const mockIsOwner = vi.fn();
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
canAccessTrip: (...args: any[]) => mockCanAccessTrip(...args),
|
||||
isOwner: (...args: any[]) => mockIsOwner(...args),
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { requireTripAccess, requireTripOwner } from '../../../src/middleware/tripAccess';
|
||||
|
||||
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
const res = { status } as unknown as Response;
|
||||
return { res, status, json };
|
||||
}
|
||||
|
||||
function makeReq(params: Record<string, string> = {}, userId = 1): Request {
|
||||
return {
|
||||
params,
|
||||
user: { id: userId },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanAccessTrip.mockReset();
|
||||
mockIsOwner.mockReset();
|
||||
});
|
||||
|
||||
// ── requireTripAccess ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireTripAccess', () => {
|
||||
it('TRIP-ACCESS-001: returns 400 when no tripId param', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripAccess(makeReq({}), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-002: returns 404 when canAccessTrip returns null (not a member)', () => {
|
||||
mockCanAccessTrip.mockReturnValue(null);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripAccess(makeReq({ tripId: '42' }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-003: calls next and attaches trip when user has access', () => {
|
||||
const fakeTrip = { id: 42, user_id: 1 };
|
||||
mockCanAccessTrip.mockReturnValue(fakeTrip);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
const req = makeReq({ tripId: '42' }, 1);
|
||||
requireTripAccess(req, res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect((req as any).trip).toEqual(fakeTrip);
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-004: accepts req.params.id as fallback when tripId is absent', () => {
|
||||
const fakeTrip = { id: 7, user_id: 2 };
|
||||
mockCanAccessTrip.mockReturnValue(fakeTrip);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripAccess(makeReq({ id: '7' }), res, next);
|
||||
expect(mockCanAccessTrip).toHaveBeenCalledWith(7, expect.any(Number));
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-005: passes numeric tripId to canAccessTrip', () => {
|
||||
mockCanAccessTrip.mockReturnValue({ id: 99, user_id: 3 });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripAccess(makeReq({ tripId: '99' }, 3), res, next);
|
||||
expect(mockCanAccessTrip).toHaveBeenCalledWith(99, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireTripOwner ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireTripOwner', () => {
|
||||
it('TRIP-ACCESS-006: returns 400 when no tripId param', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripOwner(makeReq({}), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-007: returns 403 when user is not the owner', () => {
|
||||
mockIsOwner.mockReturnValue(false);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '10' }, 2), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-008: calls next when user is the owner', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '10' }, 1), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-009: accepts req.params.id as fallback when tripId is absent', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ id: '5' }, 1), res, next);
|
||||
expect(mockIsOwner).toHaveBeenCalledWith(5, 1);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-010: passes numeric tripId to isOwner', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '77' }, 4), res, next);
|
||||
expect(mockIsOwner).toHaveBeenCalledWith(77, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* Unit tests for adminService — ADMIN-SVC-001 through ADMIN-SVC-050.
|
||||
* Uses a real in-memory SQLite DB. Focuses on validation/error branches
|
||||
* that the integration tests don't exercise.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, 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: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
encrypt_api_key: (v: string) => v,
|
||||
decrypt_api_key: (v: string) => v,
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
vi.mock('../../../src/mcp', () => ({
|
||||
revokeUserSessions: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/demo/demo-reset', () => ({
|
||||
saveBaseline: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
|
||||
import {
|
||||
listUsers,
|
||||
createUser as svcCreateUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getStats,
|
||||
getPermissions,
|
||||
savePermissions,
|
||||
getAuditLog,
|
||||
listInvites,
|
||||
createInvite,
|
||||
deleteInvite,
|
||||
getBagTracking,
|
||||
updateBagTracking,
|
||||
listPackingTemplates,
|
||||
createPackingTemplate,
|
||||
updatePackingTemplate,
|
||||
deletePackingTemplate,
|
||||
createTemplateCategory,
|
||||
updateTemplateCategory,
|
||||
deleteTemplateCategory,
|
||||
getPackingTemplate,
|
||||
createTemplateItem,
|
||||
updateTemplateItem,
|
||||
deleteTemplateItem,
|
||||
getOidcSettings,
|
||||
updateOidcSettings,
|
||||
saveDemoBaseline,
|
||||
getGithubReleases,
|
||||
checkVersion,
|
||||
listAddons,
|
||||
updateAddon,
|
||||
listMcpTokens,
|
||||
deleteMcpToken,
|
||||
} from '../../../src/services/adminService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── listUsers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('ADMIN-SVC-001 — returns all users with online:false', () => {
|
||||
createUser(testDb);
|
||||
createUser(testDb);
|
||||
const users = listUsers() as any[];
|
||||
expect(users.length).toBeGreaterThanOrEqual(2);
|
||||
expect(users.every((u: any) => u.online === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createUser ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createUser (service)', () => {
|
||||
it('ADMIN-SVC-002 — creates a user successfully', () => {
|
||||
const result = svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' }) as any;
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.user.email).toBe('new@test.com');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-003 — returns 400 when username is missing', () => {
|
||||
const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
|
||||
const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any;
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/invalid role/i);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-005 — returns 409 for duplicate username', () => {
|
||||
createUser(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
const result = svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' }) as any;
|
||||
expect(result.status).toBe(409);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-006 — returns 409 for duplicate email', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any;
|
||||
expect(result.status).toBe(409);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-007 — returns 400 for weak password', () => {
|
||||
const result = svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' }) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateUser ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('ADMIN-SVC-008 — updates username successfully', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateUser(String(user.id), { username: 'updatedname' }) as any;
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.user.username).toBe('updatedname');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-009 — returns 404 for non-existent user', () => {
|
||||
const result = updateUser('99999', { username: 'ghost' }) as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-010 — returns 400 for invalid role', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateUser(String(user.id), { role: 'superadmin' }) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-011 — returns 409 when username is taken', () => {
|
||||
const { user: u1 } = createUser(testDb);
|
||||
const { user: u2 } = createUser(testDb);
|
||||
const result = updateUser(String(u2.id), { username: u1.username }) as any;
|
||||
expect(result.status).toBe(409);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-012 — returns 409 when email is taken', () => {
|
||||
const { user: u1 } = createUser(testDb);
|
||||
const { user: u2 } = createUser(testDb);
|
||||
const result = updateUser(String(u2.id), { email: u1.email }) as any;
|
||||
expect(result.status).toBe(409);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-013 — returns 400 for weak password', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateUser(String(user.id), { password: 'weak' }) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-014 — tracks changed fields in result', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateUser(String(user.id), { username: 'newname', role: 'admin' }) as any;
|
||||
expect(result.changed).toContain('username');
|
||||
expect(result.changed).toContain('role');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteUser ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('ADMIN-SVC-015 — deletes user successfully', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
const result = deleteUser(String(user.id), admin.id) as any;
|
||||
expect(result.email).toBe(user.email);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-016 — returns 400 when deleting own account', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = deleteUser(String(admin.id), admin.id) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-017 — returns 404 for non-existent user', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = deleteUser('99999', admin.id) as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getStats ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getStats', () => {
|
||||
it('ADMIN-SVC-018 — returns numeric counts for all stats', () => {
|
||||
const stats = getStats() as any;
|
||||
expect(typeof stats.totalUsers).toBe('number');
|
||||
expect(typeof stats.totalTrips).toBe('number');
|
||||
expect(typeof stats.totalPlaces).toBe('number');
|
||||
expect(typeof stats.totalFiles).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPermissions / savePermissions ─────────────────────────────────────────
|
||||
|
||||
describe('Permissions', () => {
|
||||
it('ADMIN-SVC-019 — getPermissions returns an array of actions', () => {
|
||||
const result = getPermissions() as any;
|
||||
expect(Array.isArray(result.permissions)).toBe(true);
|
||||
expect(result.permissions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-020 — savePermissions persists a permission change', () => {
|
||||
savePermissions({ trip_create: 'admin' });
|
||||
const result = getPermissions() as any;
|
||||
const perm = result.permissions.find((p: any) => p.key === 'trip_create');
|
||||
expect(perm.level).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAuditLog ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAuditLog', () => {
|
||||
it('ADMIN-SVC-021 — returns entries array with total', () => {
|
||||
const result = getAuditLog({}) as any;
|
||||
expect(Array.isArray(result.entries)).toBe(true);
|
||||
expect(typeof result.total).toBe('number');
|
||||
expect(result.limit).toBe(100);
|
||||
expect(result.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-022 — respects limit and offset params', () => {
|
||||
const result = getAuditLog({ limit: '10', offset: '0' }) as any;
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-023 — caps limit at 500', () => {
|
||||
const result = getAuditLog({ limit: '9999' }) as any;
|
||||
expect(result.limit).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Invites', () => {
|
||||
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = createInvite(admin.id, { max_uses: 5 }) as any;
|
||||
expect(result.invite.token).toBeDefined();
|
||||
expect(result.invite.max_uses).toBe(5);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = createInvite(admin.id, {}) as any;
|
||||
expect(result.uses).toBe(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-026 — listInvites returns array', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
createInvite(admin.id, {});
|
||||
const invites = listInvites() as any[];
|
||||
expect(invites.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
|
||||
const result = deleteInvite(String(invite.id)) as any;
|
||||
expect(result.error).toBeUndefined();
|
||||
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
|
||||
expect(check).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
|
||||
const result = deleteInvite('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bag tracking ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bag tracking', () => {
|
||||
it('ADMIN-SVC-029 — getBagTracking returns enabled state', () => {
|
||||
const result = getBagTracking() as any;
|
||||
expect(typeof result.enabled).toBe('boolean');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-030 — updateBagTracking persists the value', () => {
|
||||
updateBagTracking(true);
|
||||
expect((getBagTracking() as any).enabled).toBe(true);
|
||||
updateBagTracking(false);
|
||||
expect((getBagTracking() as any).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Packing templates ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Packing templates', () => {
|
||||
it('ADMIN-SVC-031 — createPackingTemplate returns template', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = createPackingTemplate('Beach Trip', admin.id) as any;
|
||||
expect(result.template.name).toBe('Beach Trip');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const result = createPackingTemplate('', admin.id) as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-033 — listPackingTemplates returns array', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
createPackingTemplate('Template A', admin.id);
|
||||
const templates = listPackingTemplates() as any[];
|
||||
expect(templates.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-034 — updatePackingTemplate updates name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const created = createPackingTemplate('Old Name', admin.id) as any;
|
||||
const result = updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any;
|
||||
expect(result.template.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', () => {
|
||||
const result = updatePackingTemplate('99999', { name: 'Ghost' }) as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-036 — deletePackingTemplate removes template', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const created = createPackingTemplate('To Delete', admin.id) as any;
|
||||
const result = deletePackingTemplate(String(created.template.id)) as any;
|
||||
expect(result.name).toBe('To Delete');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', () => {
|
||||
const result = deletePackingTemplate('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Template categories ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Template categories', () => {
|
||||
it('ADMIN-SVC-038 — createTemplateCategory creates a category', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const result = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
|
||||
expect(result.category.name).toBe('Clothing');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const result = createTemplateCategory(String(tpl.template.id), '') as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', () => {
|
||||
const result = createTemplateCategory('99999', 'Clothing') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-041 — updateTemplateCategory updates name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Old') as any;
|
||||
const result = updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any;
|
||||
expect(result.category.name).toBe('New');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const result = updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-043 — deleteTemplateCategory removes category', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Remove Me') as any;
|
||||
const result = deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any;
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const result = deleteTemplateCategory(String(tpl.template.id), '99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
|
||||
|
||||
describe('getAuditLog — JSON details', () => {
|
||||
it('ADMIN-SVC-045 — parses JSON details when present', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
|
||||
user.id, 'test_action', JSON.stringify({ key: 'val' })
|
||||
);
|
||||
const result = getAuditLog({}) as any;
|
||||
expect(result.entries.length).toBeGreaterThanOrEqual(1);
|
||||
const entry = result.entries.find((e: any) => e.action === 'test_action');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-046 — handles invalid JSON gracefully with _parse_error flag', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
|
||||
user.id, 'bad_json_action', 'not-valid-json{'
|
||||
);
|
||||
const result = getAuditLog({}) as any;
|
||||
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.details).toEqual({ _parse_error: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── OIDC Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('OIDC Settings', () => {
|
||||
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => {
|
||||
const result = getOidcSettings() as any;
|
||||
expect(result.issuer).toBe('');
|
||||
expect(result.client_id).toBe('');
|
||||
expect(result.oidc_only).toBe(false);
|
||||
expect(result.client_secret_set).toBe(false);
|
||||
expect(result.display_name).toBe('');
|
||||
expect(result.discovery_url).toBe('');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => {
|
||||
updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
|
||||
const result = getOidcSettings() as any;
|
||||
expect(result.issuer).toBe('https://auth.example.com');
|
||||
expect(result.client_id).toBe('my-client');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-049 — updateOidcSettings sets oidc_only flag correctly', () => {
|
||||
updateOidcSettings({ oidc_only: true });
|
||||
const enabled = getOidcSettings() as any;
|
||||
expect(enabled.oidc_only).toBe(true);
|
||||
|
||||
updateOidcSettings({ oidc_only: false });
|
||||
const disabled = getOidcSettings() as any;
|
||||
expect(disabled.oidc_only).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── saveDemoBaseline ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('saveDemoBaseline', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-050 — returns 404 when DEMO_MODE is not "true"', () => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
const result = saveDemoBaseline() as any;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-051 — returns a defined result object when DEMO_MODE is "true"', () => {
|
||||
// saveDemoBaseline() uses a dynamic CJS require() whose mock cannot be
|
||||
// intercepted via vi.mock in this test environment (tsx runtime + CJS loader).
|
||||
// The function either succeeds (message) or falls through the catch to a
|
||||
// 500 error. Either way the result must be a defined, non-null object.
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const result = saveDemoBaseline() as any;
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('object');
|
||||
// The 404 branch must NOT be taken — DEMO_MODE is "true".
|
||||
expect(result.status).not.toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getGithubReleases ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGithubReleases', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-052 — returns empty array when fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
const result = await getGithubReleases();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-053 — returns releases array when fetch succeeds', async () => {
|
||||
const mockReleases = [
|
||||
{ id: 1, tag_name: 'v3.0.0', name: 'Release 3.0.0', html_url: 'https://github.com/example/releases/tag/v3.0.0' },
|
||||
{ id: 2, tag_name: 'v2.9.9', name: 'Release 2.9.9', html_url: 'https://github.com/example/releases/tag/v2.9.9' },
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockReleases,
|
||||
}));
|
||||
const result = await getGithubReleases();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2);
|
||||
expect((result as any[])[0].tag_name).toBe('v3.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkVersion ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('checkVersion', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-054 — returns update_available:false when fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
const result = await checkVersion() as any;
|
||||
expect(result.update_available).toBe(false);
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.latest).toBeDefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-055 — returns update_available:true when latest version is greater than current', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
|
||||
}));
|
||||
const result = await checkVersion() as any;
|
||||
expect(result.update_available).toBe(true);
|
||||
expect(result.latest).toBe('999.0.0');
|
||||
expect(result.release_url).toBe('https://github.com/example/releases/tag/v999.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPackingTemplate ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getPackingTemplate', () => {
|
||||
it('ADMIN-SVC-056 — returns template with categories and items when template exists', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Full Template', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
|
||||
createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt');
|
||||
|
||||
const result = getPackingTemplate(String(tpl.template.id)) as any;
|
||||
expect(result.template).toBeDefined();
|
||||
expect(result.template.name).toBe('Full Template');
|
||||
expect(Array.isArray(result.categories)).toBe(true);
|
||||
expect(result.categories.length).toBeGreaterThanOrEqual(1);
|
||||
expect(Array.isArray(result.items)).toBe(true);
|
||||
expect(result.items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.items[0].name).toBe('T-Shirt');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-057 — returns 404 for non-existent template', () => {
|
||||
const result = getPackingTemplate('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Template items ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Template items', () => {
|
||||
it('ADMIN-SVC-058 — createTemplateItem returns item with name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
|
||||
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any;
|
||||
expect(result.item).toBeDefined();
|
||||
expect(result.item.name).toBe('Backpack');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
|
||||
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any;
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const result = createTemplateItem(String(tpl.template.id), '99999', 'Item') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-061 — updateTemplateItem updates name', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
|
||||
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any;
|
||||
const result = updateTemplateItem(String(item.item.id), { name: 'New Item' }) as any;
|
||||
expect(result.item.name).toBe('New Item');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', () => {
|
||||
const result = updateTemplateItem('99999', { name: 'Ghost' }) as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-063 — deleteTemplateItem removes item', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const tpl = createPackingTemplate('Tpl', admin.id) as any;
|
||||
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
|
||||
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any;
|
||||
const result = deleteTemplateItem(String(item.item.id)) as any;
|
||||
expect(result.error).toBeUndefined();
|
||||
const check = testDb.prepare('SELECT id FROM packing_template_items WHERE id = ?').get(item.item.id);
|
||||
expect(check).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', () => {
|
||||
const result = deleteTemplateItem('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── listAddons ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listAddons', () => {
|
||||
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', () => {
|
||||
const result = listAddons() as any[];
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
const addonIds = result.map((a: any) => a.id);
|
||||
expect(addonIds).toContain('packing');
|
||||
expect(addonIds).toContain('budget');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAddon ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateAddon', () => {
|
||||
it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', () => {
|
||||
const disabled = updateAddon('mcp', { enabled: false }) as any;
|
||||
expect(disabled.addon).toBeDefined();
|
||||
expect(disabled.addon.enabled).toBe(false);
|
||||
|
||||
const enabled = updateAddon('mcp', { enabled: true }) as any;
|
||||
expect(enabled.addon.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', () => {
|
||||
const result = updateAddon('nonexistent-addon-xyz', { enabled: true }) as any;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── MCP Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MCP Tokens', () => {
|
||||
it('ADMIN-SVC-068 — listMcpTokens returns empty array initially', () => {
|
||||
const result = listMcpTokens() as any[];
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-069 — deleteMcpToken returns 404 for non-existent token', () => {
|
||||
const result = deleteMcpToken('99999') as any;
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ────────
|
||||
|
||||
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);
|
||||
|
||||
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 { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
|
||||
|
||||
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
|
||||
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, name, address, cat?.id ?? null);
|
||||
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Stub fetch so reverseGeocodeCountry never makes real HTTP calls
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getStats', () => {
|
||||
it('ATLAS-UNIT-001: returns mostVisited null when trips have no resolvable countries (guards reduce on empty array)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Mystery Trip' });
|
||||
// Place with no address and no coordinates → can't resolve country
|
||||
insertPlace(testDb, trip.id, 'Unknown Place', null);
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.mostVisited).toBeNull();
|
||||
expect(stats.countries).toEqual([]);
|
||||
expect(stats.stats.totalPlaces).toBe(1);
|
||||
expect(stats.stats.totalCountries).toBe(0);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Euro Tour' });
|
||||
|
||||
// 3 places in France, 1 in Germany → France should win
|
||||
for (let i = 0; i < 3; i++) {
|
||||
insertPlace(testDb, trip.id, `Paris Place ${i}`, `Street ${i}, Paris, France`);
|
||||
}
|
||||
insertPlace(testDb, trip.id, 'Berlin Place', 'Some Street, Berlin, Germany');
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.mostVisited).not.toBeNull();
|
||||
expect(stats.mostVisited!.code).toBe('FR');
|
||||
expect(stats.mostVisited!.placeCount).toBe(3);
|
||||
expect(stats.countries).toHaveLength(2);
|
||||
expect(stats.stats.totalCountries).toBe(2);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-003: returns manually marked countries when user has no trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
|
||||
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'AU');
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.countries).toHaveLength(2);
|
||||
expect(stats.countries.map((c: { code: string }) => c.code).sort()).toEqual(['AU', 'JP']);
|
||||
expect(stats.stats.totalTrips).toBe(0);
|
||||
expect(stats.stats.totalCountries).toBe(2);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Italy Trip' });
|
||||
insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.mostVisited).not.toBeNull();
|
||||
expect(stats.mostVisited!.code).toBe('IT');
|
||||
expect(stats.mostVisited!.placeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCached / setCache ────────────────────────────────────────────────────
|
||||
|
||||
describe('getCached and setCache', () => {
|
||||
it('ATLAS-SVC-001: getCached returns undefined for unknown coordinates', () => {
|
||||
// Use uniquely large lat values to guarantee no prior cache entry
|
||||
const result = getCached(9001.001, 9001.001);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-002: setCache then getCached returns the stored code', () => {
|
||||
setCache(9002.002, 9002.002, 'DE');
|
||||
const result = getCached(9002.002, 9002.002);
|
||||
expect(result).toBe('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-003: setCache can store null (country unknown)', () => {
|
||||
setCache(9003.003, 9003.003, null);
|
||||
const result = getCached(9003.003, 9003.003);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-004: different coordinates return different cached values', () => {
|
||||
setCache(9004.004, 9004.004, 'FR');
|
||||
setCache(9004.005, 9004.005, 'ES');
|
||||
expect(getCached(9004.004, 9004.004)).toBe('FR');
|
||||
expect(getCached(9004.005, 9004.005)).toBe('ES');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCountryFromCoords ────────────────────────────────────────────────────
|
||||
|
||||
describe('getCountryFromCoords', () => {
|
||||
it('ATLAS-SVC-005: returns country code for Paris coordinates (France)', () => {
|
||||
// Paris: approximately 48.85°N, 2.35°E — well inside FR bounding box
|
||||
const code = getCountryFromCoords(48.85, 2.35);
|
||||
expect(code).toBe('FR');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-006: returns country code for NYC coordinates (USA)', () => {
|
||||
// New York City: approximately 40.71°N, -74.0°W — inside US bounding box
|
||||
const code = getCountryFromCoords(40.71, -74.0);
|
||||
expect(code).toBe('US');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-007: returns null for coordinates with no country match (0,0)', () => {
|
||||
// Gulf of Guinea — no COUNTRY_BOXES entry covers 0°N, 0°E
|
||||
const code = getCountryFromCoords(0.0, 0.0);
|
||||
expect(code).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCountryFromAddress ───────────────────────────────────────────────────
|
||||
|
||||
describe('getCountryFromAddress', () => {
|
||||
it('ATLAS-SVC-008: returns null for null address', () => {
|
||||
expect(getCountryFromAddress(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-009: returns null for empty string', () => {
|
||||
expect(getCountryFromAddress('')).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-010: parses "France" in last position to "FR"', () => {
|
||||
expect(getCountryFromAddress('Eiffel Tower, Paris, France')).toBe('FR');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-011: returns 2-letter ISO code directly when last part is uppercase 2-letter', () => {
|
||||
// "US" is uppercase and exactly 2 characters — returned verbatim
|
||||
expect(getCountryFromAddress('123 Main St, New York, US')).toBe('US');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-012: returns null for unrecognized country name', () => {
|
||||
expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── reverseGeocodeCountry ───────────────────────────────────────────────────
|
||||
|
||||
describe('reverseGeocodeCountry', () => {
|
||||
it('ATLAS-SVC-013: returns null when fetch fails (ok:false)', async () => {
|
||||
// The beforeEach stub already returns ok:false — this is the default path
|
||||
const code = await reverseGeocodeCountry(9013.013, 9013.013);
|
||||
expect(code).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ address: { country_code: 'fr' } }),
|
||||
}));
|
||||
// Berlin-ish coords not used elsewhere — unique to avoid cache collision
|
||||
const code = await reverseGeocodeCountry(52.52, 13.40);
|
||||
expect(code).toBe('FR');
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-015: returns null when fetch throws a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
const code = await reverseGeocodeCountry(9015.015, 9015.015);
|
||||
expect(code).toBeNull();
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-016: returns cached result on second call (fetch called only once)', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ address: { country_code: 'gb' } }),
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// Use unique coords so neither call hits a prior cache entry
|
||||
const first = await reverseGeocodeCountry(9016.016, 9016.016);
|
||||
const second = await reverseGeocodeCountry(9016.016, 9016.016);
|
||||
|
||||
expect(first).toBe('GB');
|
||||
expect(second).toBe('GB');
|
||||
// fetch should have been invoked only once; the second call uses the in-memory cache
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getRegionGeo ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRegionGeo', () => {
|
||||
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
|
||||
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
|
||||
// returning null — which causes getRegionGeo to return the empty FeatureCollection.
|
||||
// (The default ok:false stub does NOT trigger the catch; it still resolves json()
|
||||
// to {}, which loadAdmin1Geo caches as a non-null truthy value.)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
|
||||
const result = await getRegionGeo(['DE', 'FR']);
|
||||
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
|
||||
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
|
||||
// admin1GeoLoading is null — this test's fetch override will be called.
|
||||
const mockGeoJson = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
|
||||
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
|
||||
],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockGeoJson,
|
||||
}));
|
||||
|
||||
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
|
||||
const result = await getRegionGeo(['de']);
|
||||
|
||||
expect(result.type).toBe('FeatureCollection');
|
||||
expect(result.features).toHaveLength(1);
|
||||
expect(result.features[0].properties.iso_a2).toBe('DE');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helpers for new tests ────────────────────────────────────────────────────
|
||||
|
||||
function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) {
|
||||
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, name, address, lat, lng, cat?.id ?? null);
|
||||
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
// ── getStats — extended ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getStats — extended', () => {
|
||||
it('ATLAS-UNIT-005: totalDays is calculated when trip has start_date and end_date', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Short Trip', start_date: '2024-03-01', end_date: '2024-03-03' });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
// March 1, 2, 3 → diff = 2 + 1 = 3
|
||||
expect(stats.stats.totalDays).toBe(3);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-006: totalDays is 0 when trip has no dates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Dateless' });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.stats.totalDays).toBe(0);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-007: manually marked country is merged when user has trips but no resolvable places for that country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTrip(testDb, user.id, { title: 'Japan Trip', start_date: '2024-01-01', end_date: '2024-01-10' });
|
||||
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
const codes = stats.countries.map((c: any) => c.code);
|
||||
expect(codes).toContain('JP');
|
||||
const jp = stats.countries.find((c: any) => c.code === 'JP');
|
||||
expect(jp?.placeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
|
||||
insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France');
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.lastTrip).not.toBeNull();
|
||||
expect(stats.lastTrip!.countryCode).toBe('FR');
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-009: nextTrip has daysUntil calculated', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
||||
const futureDateStr = futureDate.toISOString().split('T')[0];
|
||||
createTrip(testDb, user.id, { title: 'Future Trip', start_date: futureDateStr });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.nextTrip).not.toBeNull();
|
||||
expect(stats.nextTrip!.daysUntil).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const currentYear = new Date().getFullYear();
|
||||
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` });
|
||||
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.streak).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.firstYear).toBe(currentYear - 1);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-011: tripsThisYear counts only trips whose start_date is in the current year', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const currentYear = new Date().getFullYear();
|
||||
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-03-01` });
|
||||
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-03-01` });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.tripsThisYear).toBe(1);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-012: lastTrip is null when all trips end in the future', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const nextYear = new Date().getFullYear() + 1;
|
||||
createTrip(testDb, user.id, { title: 'Future', start_date: `${nextYear}-01-01`, end_date: `${nextYear}-01-10` });
|
||||
|
||||
const stats = await getStats(user.id);
|
||||
|
||||
expect(stats.lastTrip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCountryPlaces ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getCountryPlaces', () => {
|
||||
it('ATLAS-UNIT-013: returns empty result when user has no trips', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const result = getCountryPlaces(user.id, 'FR');
|
||||
|
||||
expect(result.places).toHaveLength(0);
|
||||
expect(result.trips).toHaveLength(0);
|
||||
expect(result.manually_marked).toBe(false);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'France Trip' });
|
||||
insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
|
||||
insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany');
|
||||
|
||||
const result = getCountryPlaces(user.id, 'FR');
|
||||
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].name).toBe('Louvre');
|
||||
expect(result.trips).toHaveLength(1);
|
||||
expect(result.trips[0].id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
|
||||
createTrip(testDb, user.id, { title: 'Japan' });
|
||||
|
||||
const result = getCountryPlaces(user.id, 'JP');
|
||||
|
||||
expect(result.manually_marked).toBe(true);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Coord Trip' });
|
||||
// Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box
|
||||
insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35);
|
||||
|
||||
const result = getCountryPlaces(user.id, 'FR');
|
||||
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].name).toBe('Secret Paris Spot');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getVisitedRegions ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getVisitedRegions', () => {
|
||||
it('ATLAS-UNIT-017: returns empty regions object when user has no trips', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const result = await getVisitedRegions(user.id);
|
||||
|
||||
expect(result.regions).toEqual({});
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
|
||||
testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE');
|
||||
|
||||
const result = await getVisitedRegions(user.id);
|
||||
|
||||
expect(result.regions['DE']).toBeDefined();
|
||||
const codes = result.regions['DE'].map((r: any) => r.code);
|
||||
expect(codes).toContain('DE-BY');
|
||||
const bayernRegion = result.regions['DE'].find((r: any) => r.code === 'DE-BY');
|
||||
expect(bayernRegion?.manuallyMarked).toBe(true);
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
address: {
|
||||
country_code: 'fr',
|
||||
'ISO3166-2-lvl4': 'FR-75',
|
||||
state: 'Île-de-France',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
|
||||
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
|
||||
|
||||
const resultPromise = getVisitedRegions(user.id);
|
||||
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.regions['FR']).toBeDefined();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('ATLAS-UNIT-020: places already cached in place_regions are not re-geocoded', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Cached Trip' });
|
||||
const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35);
|
||||
|
||||
// Pre-populate the place_regions cache so the fetch path is never reached
|
||||
testDb.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
|
||||
).run(place.id, 'FR', 'FR-75', 'Île-de-France');
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const result = await getVisitedRegions(user.id);
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(result.regions['FR']).toBeDefined();
|
||||
const codes = result.regions['FR'].map((r: any) => r.code);
|
||||
expect(codes).toContain('FR-75');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* authServiceDb.test.ts
|
||||
*
|
||||
* DB-centric unit tests for authService.ts using a real in-memory SQLite database.
|
||||
* Pure function tests live in authService.test.ts (stub DB); this file covers
|
||||
* functions that require actual DB queries to exercise their logic.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vi.hoisted: build the real in-memory DB and the module mock before any import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: () => {},
|
||||
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-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/services/mfaCrypto', () => ({
|
||||
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
|
||||
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
|
||||
}));
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: vi.fn((v) => v),
|
||||
maybe_encrypt_api_key: vi.fn((v) => v),
|
||||
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
|
||||
encrypt_api_key: vi.fn((v) => v),
|
||||
}));
|
||||
vi.mock('../../../src/services/permissions', () => ({
|
||||
getAllPermissions: vi.fn(() => ({})),
|
||||
checkPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
vi.mock('../../../src/scheduler', () => ({
|
||||
startTripReminders: vi.fn(),
|
||||
buildCronExpression: vi.fn(),
|
||||
loadSettings: vi.fn(() => ({ enabled: false })),
|
||||
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports (after mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
|
||||
import {
|
||||
updateSettings,
|
||||
getSettings,
|
||||
listUsers,
|
||||
getAppSettings,
|
||||
validateKeys,
|
||||
isOidcOnlyMode,
|
||||
setupMfa,
|
||||
enableMfa,
|
||||
disableMfa,
|
||||
validateInviteToken,
|
||||
registerUser,
|
||||
loginUser,
|
||||
changePassword,
|
||||
verifyMfaLogin,
|
||||
createMcpToken,
|
||||
deleteMcpToken,
|
||||
} from '../../../src/services/authService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => resetTestDb(testDb));
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('updateSettings', () => {
|
||||
it('AUTH-DB-001: updates username successfully', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, { username: 'newname' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.username).toBe('newname');
|
||||
});
|
||||
|
||||
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, { username: 'x' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/between 2 and 50/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, { username: 'bad name' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/only contain/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-004: returns 409 when username is already taken by another user', () => {
|
||||
const { user: user1 } = createUser(testDb, { username: 'alice' });
|
||||
const { user: user2 } = createUser(testDb, { username: 'bob' });
|
||||
const result = updateSettings(user2.id, { username: user1.username });
|
||||
expect(result.status).toBe(409);
|
||||
expect(result.error).toMatch(/already taken/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-005: updates email successfully', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, { email: 'new@example.com' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.email).toBe('new@example.com');
|
||||
});
|
||||
|
||||
it('AUTH-DB-006: returns 400 for invalid email format', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, { email: 'not-an-email' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/invalid email/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-007: returns 409 when email is already taken by another user', () => {
|
||||
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
|
||||
const { user: user2 } = createUser(testDb);
|
||||
const result = updateSettings(user2.id, { email: user1.email });
|
||||
expect(result.status).toBe(409);
|
||||
expect(result.error).toMatch(/already taken/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = updateSettings(user.id, {});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('AUTH-DB-009: returns 403 for non-admin user', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = getSettings(user.id);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
|
||||
.run('maps-key-value', 'weather-key-value', user.id);
|
||||
const result = getSettings(user.id);
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.settings).toBeDefined();
|
||||
expect(result.settings).toHaveProperty('maps_api_key');
|
||||
expect(result.settings).toHaveProperty('openweather_api_key');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listUsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('AUTH-DB-011: returns all users except self, sorted by username', () => {
|
||||
const { user: self } = createUser(testDb, { username: 'zzself' });
|
||||
createUser(testDb, { username: 'alice' });
|
||||
createUser(testDb, { username: 'charlie' });
|
||||
createUser(testDb, { username: 'bob' });
|
||||
const result = listUsers(self.id);
|
||||
expect(result).toHaveLength(3);
|
||||
const names = result.map((u) => u.username);
|
||||
expect(names).toEqual([...names].sort());
|
||||
expect(names).not.toContain('zzself');
|
||||
});
|
||||
|
||||
it('AUTH-DB-012: returns empty array when only one user exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = listUsers(user.id);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAppSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAppSettings', () => {
|
||||
it('AUTH-DB-013: returns 403 for non-admin', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = getAppSettings(user.id);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/admin/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
|
||||
.run();
|
||||
const result = getAppSettings(user.id);
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data).toHaveProperty('allow_registration', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateKeys', () => {
|
||||
it('AUTH-DB-015: returns 403 for non-admin', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = await validateKeys(user.id);
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/admin/i);
|
||||
expect(result.maps).toBe(false);
|
||||
expect(result.weather).toBe(false);
|
||||
});
|
||||
|
||||
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
const result = await validateKeys(user.id);
|
||||
expect(result.maps).toBe(false);
|
||||
expect(result.weather).toBe(false);
|
||||
expect(result.maps_details).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
||||
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => '',
|
||||
} as Response);
|
||||
|
||||
const result = await validateKeys(user.id);
|
||||
expect(result.maps).toBe(true);
|
||||
expect(result.maps_details?.ok).toBe(true);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => {
|
||||
const { user } = createAdmin(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(global, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('Network failure'));
|
||||
|
||||
const result = await validateKeys(user.id);
|
||||
expect(result.maps).toBe(false);
|
||||
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
|
||||
expect(result.maps_details?.error_message).toBe('Network failure');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isOidcOnlyMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isOidcOnlyMode', () => {
|
||||
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => {
|
||||
vi.stubEnv('OIDC_ONLY', '');
|
||||
expect(isOidcOnlyMode()).toBe(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', '');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', '');
|
||||
expect(isOidcOnlyMode()).toBe(false);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => {
|
||||
vi.stubEnv('OIDC_ONLY', 'true');
|
||||
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
|
||||
expect(isOidcOnlyMode()).toBe(true);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setupMfa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setupMfa', () => {
|
||||
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const result = setupMfa(user.id, 'demo@nomad.app');
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/demo mode/i);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-023: returns 400 when MFA is already enabled', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
|
||||
const result = setupMfa(user.id, user.email);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/already enabled/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = setupMfa(user.id, user.email);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(typeof result.secret).toBe('string');
|
||||
expect(result.secret!.length).toBeGreaterThan(0);
|
||||
expect(typeof result.otpauth_url).toBe('string');
|
||||
expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(result.qrPromise).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// enableMfa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('enableMfa', () => {
|
||||
it('AUTH-DB-025: returns 400 when no verification code is provided', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = enableMfa(user.id, undefined);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/code is required/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// No setupMfa called first, so no pending entry exists
|
||||
const result = enableMfa(user.id, '123456');
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/no mfa setup in progress/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// disableMfa
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('disableMfa', () => {
|
||||
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const result = disableMfa(user.id, 'demo@nomad.app', {
|
||||
password: 'password123',
|
||||
code: '000000',
|
||||
});
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/demo mode/i);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('AUTH-DB-028: returns 400 when password or code is missing', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const missingCode = disableMfa(user.id, user.email, { password: 'pass', code: undefined });
|
||||
expect(missingCode.status).toBe(400);
|
||||
expect(missingCode.error).toMatch(/password and authenticator code/i);
|
||||
|
||||
const missingPassword = disableMfa(user.id, user.email, { password: undefined, code: '123456' });
|
||||
expect(missingPassword.status).toBe(400);
|
||||
expect(missingPassword.error).toMatch(/password and authenticator code/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => {
|
||||
const { user } = createUser(testDb);
|
||||
// mfa_enabled defaults to 0 / not set
|
||||
const result = disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/not enabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateInviteToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('validateInviteToken', () => {
|
||||
it('AUTH-DB-030: returns 404 for unknown token', () => {
|
||||
const result = validateInviteToken('no-such-token');
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-DB-031: returns 410 when max_uses exceeded', () => {
|
||||
// createInviteToken with used_count already at max
|
||||
const invite = createInviteToken(testDb, { max_uses: 1 });
|
||||
// manually set used_count = 1 to simulate exhaustion
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
const result = validateInviteToken(invite.token);
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
|
||||
it('AUTH-DB-032: returns 410 when expired', () => {
|
||||
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
|
||||
const result = validateInviteToken(invite.token);
|
||||
expect(result.status).toBe(410);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerUser — OIDC-only / registration-disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('registerUser — OIDC-only / registration-disabled', () => {
|
||||
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => {
|
||||
createUser(testDb); // ensure userCount > 0
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
|
||||
const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/SSO/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => {
|
||||
createUser(testDb); // ensure userCount > 0
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
|
||||
const result = registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
|
||||
expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loginUser — OIDC-only mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('loginUser — OIDC-only mode', () => {
|
||||
it('AUTH-DB-035: returns 403 when oidc_only=true', () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
|
||||
const result = loginUser({ email: user.email, password });
|
||||
expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// changePassword — OIDC-only mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('changePassword — OIDC-only mode', () => {
|
||||
it('AUTH-DB-036: returns 403 when oidc_only=true', () => {
|
||||
const { user, password } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
|
||||
|
||||
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
||||
expect(result.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// disableMfa — require_mfa policy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('disableMfa — require_mfa policy', () => {
|
||||
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
const result = disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.error).toMatch(/cannot be disabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verifyMfaLogin — validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verifyMfaLogin — validation', () => {
|
||||
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => {
|
||||
const result = verifyMfaLogin({ mfa_token: undefined, code: undefined });
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.error).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const jwt = require('jsonwebtoken');
|
||||
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP token service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MCP token service', () => {
|
||||
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = createMcpToken(user.id, undefined);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = createMcpToken(user.id, 'a'.repeat(101));
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = createMcpToken(user.id, 'My Token');
|
||||
expect(result.token).toBeDefined();
|
||||
expect((result.token as any).raw_token).toMatch(/^trek_/);
|
||||
});
|
||||
|
||||
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
|
||||
const { user } = createUser(testDb);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
testDb.prepare(
|
||||
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
|
||||
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
|
||||
}
|
||||
const result = createMcpToken(user.id, 'One More');
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const result = deleteMcpToken(user.id, '99999');
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createMcpToken(user.id, 'Deletable Token');
|
||||
const tokenId = String((created.token as any).id);
|
||||
|
||||
const result = deleteMcpToken(user.id, tokenId);
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* Unit tests for backupService.
|
||||
* Covers BACKUP-031 to BACKUP-060.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks — must be defined before any vi.mock() calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
createWriteStream: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
cpSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const archiverInstanceMock = vi.hoisted(() => ({
|
||||
pipe: vi.fn(),
|
||||
file: vi.fn(),
|
||||
directory: vi.fn(),
|
||||
finalize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}));
|
||||
|
||||
const archiverMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const unzipperMock = vi.hoisted(() => ({
|
||||
Extract: vi.fn(),
|
||||
}));
|
||||
|
||||
const dbMock = vi.hoisted(() => ({
|
||||
db: {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
},
|
||||
closeDb: vi.fn(),
|
||||
reinitialize: vi.fn(),
|
||||
getPlaceWithTags: vi.fn(),
|
||||
canAccessTrip: vi.fn(),
|
||||
isOwner: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a'.repeat(64),
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
|
||||
vi.mock('archiver', () => ({ default: archiverMock }));
|
||||
vi.mock('unzipper', () => ({ default: unzipperMock }));
|
||||
vi.mock('../../../src/scheduler', () => ({
|
||||
VALID_INTERVALS: ['hourly', 'daily', 'weekly', 'monthly'],
|
||||
loadSettings: vi.fn(() => ({
|
||||
enabled: false,
|
||||
interval: 'daily',
|
||||
keep_days: 7,
|
||||
hour: 2,
|
||||
day_of_week: 0,
|
||||
day_of_month: 1,
|
||||
})),
|
||||
saveSettings: vi.fn(),
|
||||
start: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
formatSize,
|
||||
parseIntField,
|
||||
parseAutoBackupBody,
|
||||
isValidBackupFilename,
|
||||
checkRateLimit,
|
||||
createBackup,
|
||||
deleteBackup,
|
||||
restoreFromZip,
|
||||
BACKUP_RATE_WINDOW,
|
||||
backupFilePath,
|
||||
backupFileExists,
|
||||
listBackups,
|
||||
updateAutoSettings,
|
||||
} from '../../../src/services/backupService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatSize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-031 formatSize', () => {
|
||||
it('formats bytes < 1024 as B', () => {
|
||||
expect(formatSize(500)).toBe('500 B');
|
||||
});
|
||||
|
||||
it('formats bytes in KB range', () => {
|
||||
expect(formatSize(1024)).toBe('1.0 KB');
|
||||
expect(formatSize(2048)).toBe('2.0 KB');
|
||||
});
|
||||
|
||||
it('formats bytes in MB range', () => {
|
||||
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||
});
|
||||
|
||||
it('boundary: exactly 1024 bytes is 1.0 KB', () => {
|
||||
expect(formatSize(1023)).toBe('1023 B');
|
||||
expect(formatSize(1024)).toBe('1.0 KB');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseIntField
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-032 parseIntField', () => {
|
||||
it('returns numeric value as-is when finite', () => {
|
||||
expect(parseIntField(5, 99)).toBe(5);
|
||||
});
|
||||
|
||||
it('floors float numbers', () => {
|
||||
expect(parseIntField(7.9, 0)).toBe(7);
|
||||
});
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
expect(parseIntField('12', 0)).toBe(12);
|
||||
});
|
||||
|
||||
it('returns fallback for non-numeric string', () => {
|
||||
expect(parseIntField('abc', 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns fallback for null', () => {
|
||||
expect(parseIntField(null, 7)).toBe(7);
|
||||
});
|
||||
|
||||
it('returns fallback for undefined', () => {
|
||||
expect(parseIntField(undefined, 7)).toBe(7);
|
||||
});
|
||||
|
||||
it('returns fallback for Infinity', () => {
|
||||
expect(parseIntField(Infinity, 5)).toBe(5);
|
||||
});
|
||||
|
||||
it('returns fallback for empty string', () => {
|
||||
expect(parseIntField('', 4)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseAutoBackupBody
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-033 parseAutoBackupBody', () => {
|
||||
it('parses all valid fields', () => {
|
||||
const result = parseAutoBackupBody({
|
||||
enabled: true,
|
||||
interval: 'weekly',
|
||||
keep_days: 14,
|
||||
hour: 6,
|
||||
day_of_week: 5,
|
||||
day_of_month: 15,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
interval: 'weekly',
|
||||
keep_days: 14,
|
||||
hour: 6,
|
||||
day_of_week: 5,
|
||||
day_of_month: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to daily when interval is invalid', () => {
|
||||
const result = parseAutoBackupBody({ interval: 'not-valid' });
|
||||
expect(result.interval).toBe('daily');
|
||||
});
|
||||
|
||||
it('clamps hour to 0-23', () => {
|
||||
expect(parseAutoBackupBody({ hour: 999 }).hour).toBe(23);
|
||||
expect(parseAutoBackupBody({ hour: -1 }).hour).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps day_of_week to 0-6', () => {
|
||||
expect(parseAutoBackupBody({ day_of_week: 10 }).day_of_week).toBe(6);
|
||||
expect(parseAutoBackupBody({ day_of_week: -1 }).day_of_week).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps day_of_month to 1-28', () => {
|
||||
expect(parseAutoBackupBody({ day_of_month: 99 }).day_of_month).toBe(28);
|
||||
expect(parseAutoBackupBody({ day_of_month: 0 }).day_of_month).toBe(1);
|
||||
});
|
||||
|
||||
it('treats enabled = "true" string as true', () => {
|
||||
expect(parseAutoBackupBody({ enabled: 'true' }).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('treats enabled = 1 as true', () => {
|
||||
expect(parseAutoBackupBody({ enabled: 1 }).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('treats enabled = false as false', () => {
|
||||
expect(parseAutoBackupBody({ enabled: false }).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isValidBackupFilename
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-034 isValidBackupFilename', () => {
|
||||
it('accepts valid backup filename', () => {
|
||||
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.zip')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(isValidBackupFilename('../../etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects filename without .zip extension', () => {
|
||||
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.tar.gz')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects filename with spaces', () => {
|
||||
expect(isValidBackupFilename('backup 2026.zip')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(isValidBackupFilename('')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts filename with hyphens and underscores', () => {
|
||||
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkRateLimit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-035 checkRateLimit', () => {
|
||||
// Each test uses a unique key to avoid state pollution between tests
|
||||
it('allows first request', () => {
|
||||
expect(checkRateLimit('test-key-1', 3, BACKUP_RATE_WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows requests up to maxAttempts', () => {
|
||||
const key = 'test-key-2';
|
||||
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
|
||||
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks request exceeding maxAttempts within window', () => {
|
||||
const key = 'test-key-3';
|
||||
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
|
||||
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
|
||||
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('resets counter after window expires', () => {
|
||||
vi.useFakeTimers();
|
||||
const key = 'test-key-4';
|
||||
const windowMs = 100;
|
||||
checkRateLimit(key, 1, windowMs);
|
||||
checkRateLimit(key, 1, windowMs); // this one is blocked
|
||||
vi.advanceTimersByTime(200);
|
||||
// After window expires, should be allowed again
|
||||
expect(checkRateLimit(key, 1, windowMs)).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createBackup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-036 createBackup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-036a — happy path: creates zip and returns BackupInfo', async () => {
|
||||
// Set up fs mocks
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
// backupsDir exists, dbPath does not (skip DB file), uploadsDir does not exist
|
||||
return false;
|
||||
});
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
// Mock WriteStream with event emitter behaviour
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
// Mock archiver instance
|
||||
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
|
||||
// noop — no error
|
||||
});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
// Trigger 'close' on the output stream to resolve the Promise
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 2048, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
const result = await createBackup();
|
||||
|
||||
expect(result).toHaveProperty('filename');
|
||||
expect(result.filename).toMatch(/^backup-.*\.zip$/);
|
||||
expect(result.size).toBe(2048);
|
||||
expect(result.sizeText).toBe('2.0 KB');
|
||||
expect(result).toHaveProperty('created_at');
|
||||
expect(archiverMock).toHaveBeenCalledWith('zip', { zlib: { level: 9 } });
|
||||
expect(archiverInstanceMock.pipe).toHaveBeenCalled();
|
||||
expect(archiverInstanceMock.finalize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-036b — WAL checkpoint error is swallowed (non-critical)', async () => {
|
||||
// db.exec throws on WAL checkpoint
|
||||
dbMock.db.exec.mockImplementationOnce(() => { throw new Error('WAL checkpoint failed'); });
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
fsMock.existsSync.mockReturnValue(false);
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_event: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 512, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
// Should not throw even though WAL checkpoint failed
|
||||
const result = await createBackup();
|
||||
expect(result).toHaveProperty('filename');
|
||||
expect(result.size).toBe(512);
|
||||
});
|
||||
|
||||
it('BACKUP-036c — archiver error cleans up partial file and re-throws', async () => {
|
||||
fsMock.existsSync.mockReturnValue(false);
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const archiveEvents: Record<string, Function> = {};
|
||||
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
|
||||
archiveEvents[event] = cb;
|
||||
});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
// Simulate archive error instead of success
|
||||
if (archiveEvents['error']) archiveEvents['error'](new Error('disk full'));
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
// The output file "exists" after partial write so cleanup runs
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
// Return true only when checking the output path (ends with .zip)
|
||||
return String(p).endsWith('.zip');
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
|
||||
await expect(createBackup()).rejects.toThrow('disk full');
|
||||
// Partial file should have been removed
|
||||
expect(fsMock.unlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BACKUP-036d — includes travel.db when it exists', async () => {
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
// backupsDir does not need to be created (exists), dbPath exists, no uploads
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
return false;
|
||||
});
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
// archive.file should have been called with the db path
|
||||
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
|
||||
expect.stringContaining('travel.db'),
|
||||
{ name: 'travel.db' }
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-036e — includes uploads directory when it exists', async () => {
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('uploads')) return true;
|
||||
return false;
|
||||
});
|
||||
fsMock.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
const writableEvents: Record<string, Function> = {};
|
||||
const fakeWriteStream = {
|
||||
on: vi.fn((event: string, cb: Function) => {
|
||||
writableEvents[event] = cb;
|
||||
}),
|
||||
};
|
||||
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
|
||||
|
||||
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
|
||||
archiverInstanceMock.pipe.mockReturnValue(undefined);
|
||||
archiverInstanceMock.finalize.mockImplementation(() => {
|
||||
if (writableEvents['close']) writableEvents['close']();
|
||||
});
|
||||
archiverMock.mockReturnValue(archiverInstanceMock);
|
||||
|
||||
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
|
||||
|
||||
await createBackup();
|
||||
|
||||
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads'),
|
||||
'uploads'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteBackup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-037 deleteBackup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-037a — happy path: calls unlinkSync with correct path', () => {
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
|
||||
deleteBackup('backup-2026-04-06T12-00-00.zip');
|
||||
|
||||
expect(fsMock.unlinkSync).toHaveBeenCalledOnce();
|
||||
expect(fsMock.unlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('backup-2026-04-06T12-00-00.zip')
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-037b — throws when unlinkSync throws (file not found)', () => {
|
||||
fsMock.unlinkSync.mockImplementation(() => {
|
||||
const err: NodeJS.ErrnoException = new Error('ENOENT: no such file or directory');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
expect(() => deleteBackup('backup-missing.zip')).toThrow('ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restoreFromZip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-038 restoreFromZip', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-038a — returns error when travel.db not found in zip', async () => {
|
||||
// Simulate successful extraction but missing travel.db
|
||||
const fakeReadStream = { pipe: vi.fn() };
|
||||
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
|
||||
fsMock.createReadStream.mockReturnValue(fakeReadStream);
|
||||
fakeReadStream.pipe.mockReturnValue(fakeExtractStream);
|
||||
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
|
||||
|
||||
// extractedDb does not exist
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return false;
|
||||
return true; // extractDir exists for cleanup
|
||||
});
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/travel\.db not found/i);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// better-sqlite3 mock — hoisted by Vitest regardless of file position
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DatabaseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('better-sqlite3', () => ({ default: DatabaseMock }));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// backupFilePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-039 backupFilePath', () => {
|
||||
it('BACKUP-039a — returns a path ending with the given filename', () => {
|
||||
const result = backupFilePath('backup-test.zip');
|
||||
expect(result).toMatch(/backup-test\.zip$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// backupFileExists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-040 backupFileExists', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-040a — returns true when existsSync returns true', () => {
|
||||
fsMock.existsSync.mockReturnValue(true);
|
||||
expect(backupFileExists('backup-2026-01-01T00-00-00.zip')).toBe(true);
|
||||
expect(fsMock.existsSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('backup-2026-01-01T00-00-00.zip')
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-040b — returns false when existsSync returns false', () => {
|
||||
fsMock.existsSync.mockReturnValue(false);
|
||||
expect(backupFileExists('backup-missing.zip')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listBackups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-041 listBackups', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// ensureBackupsDir: backupsDir already exists so mkdirSync is not called
|
||||
fsMock.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('BACKUP-041a — returns empty array when no .zip files in directory', () => {
|
||||
fsMock.readdirSync.mockReturnValue([]);
|
||||
expect(listBackups()).toEqual([]);
|
||||
});
|
||||
|
||||
it('BACKUP-041b — returns BackupInfo array for each .zip file', () => {
|
||||
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
|
||||
fsMock.statSync.mockReturnValue({
|
||||
size: 1024,
|
||||
birthtime: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
|
||||
expect(result[0].size).toBe(1024);
|
||||
expect(result[0].sizeText).toBe('1.0 KB');
|
||||
expect(result[0].created_at).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('BACKUP-041c — sorts results newest-first', () => {
|
||||
fsMock.readdirSync.mockReturnValue([
|
||||
'backup-2026-01-01T00-00-00.zip',
|
||||
'backup-2026-06-01T00-00-00.zip',
|
||||
]);
|
||||
fsMock.statSync.mockImplementation((p: string) => {
|
||||
if (String(p).includes('2026-01-01')) {
|
||||
return { size: 512, birthtime: new Date('2026-01-01T00:00:00Z') };
|
||||
}
|
||||
return { size: 2048, birthtime: new Date('2026-06-01T00:00:00Z') };
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].filename).toBe('backup-2026-06-01T00-00-00.zip');
|
||||
expect(result[1].filename).toBe('backup-2026-01-01T00-00-00.zip');
|
||||
});
|
||||
|
||||
it('BACKUP-041d — filters out non-.zip files', () => {
|
||||
fsMock.readdirSync.mockReturnValue([
|
||||
'backup-2026-01-01T00-00-00.zip',
|
||||
'README.txt',
|
||||
'backup-partial.tar.gz',
|
||||
]);
|
||||
fsMock.statSync.mockReturnValue({
|
||||
size: 1024,
|
||||
birthtime: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restoreFromZip — extended paths (BACKUP-042 through BACKUP-046)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shared helper: configures the stream mocks so extraction succeeds. */
|
||||
function setupSuccessfulExtraction() {
|
||||
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
|
||||
const fakeReadStream = { pipe: vi.fn().mockReturnValue(fakeExtractStream) };
|
||||
fsMock.createReadStream.mockReturnValue(fakeReadStream);
|
||||
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
|
||||
return { fakeReadStream, fakeExtractStream };
|
||||
}
|
||||
|
||||
describe('BACKUP-042 restoreFromZip — integrity check fails', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-042a — returns status 400 with integrity check error message', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) =>
|
||||
String(p).endsWith('travel.db')
|
||||
);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'corruption' }),
|
||||
all: vi.fn(),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/integrity check/i);
|
||||
expect(fsMock.rmSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-043 restoreFromZip — missing required table', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-043a — returns status 400 with missing required table error', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) =>
|
||||
String(p).endsWith('travel.db')
|
||||
);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn()
|
||||
.mockReturnValueOnce({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }]),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/missing required table/i);
|
||||
expect(fsMock.rmSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQLite)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-044a — returns status 400 with "not a valid SQLite database" error', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) =>
|
||||
String(p).endsWith('travel.db')
|
||||
);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
DatabaseMock.mockImplementation(() => {
|
||||
throw new Error('file is not a database');
|
||||
});
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.error).toMatch(/not a valid SQLite database/i);
|
||||
expect(fsMock.rmSync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function setupAllTablesPresent() {
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn()
|
||||
.mockReturnValueOnce({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
all: vi.fn().mockReturnValue([
|
||||
{ name: 'users' },
|
||||
{ name: 'trips' },
|
||||
{ name: 'trip_members' },
|
||||
{ name: 'places' },
|
||||
{ name: 'days' },
|
||||
]),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
return fakeDbInstance;
|
||||
}
|
||||
|
||||
it('BACKUP-045a — returns { success: true } on full success', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
const result = await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('BACKUP-045b — closeDb is called before file copy operations', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
const callOrder: string[] = [];
|
||||
dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); });
|
||||
fsMock.copyFileSync.mockImplementation(() => { callOrder.push('copyFileSync'); });
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(callOrder.indexOf('closeDb')).toBeLessThan(callOrder.indexOf('copyFileSync'));
|
||||
});
|
||||
|
||||
it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
setupAllTablesPresent();
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return false;
|
||||
return true;
|
||||
});
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockImplementation(() => {
|
||||
throw new Error('disk full');
|
||||
});
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
await expect(restoreFromZip('/data/tmp/upload.zip')).rejects.toThrow('disk full');
|
||||
|
||||
expect(dbMock.reinitialize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('BACKUP-046a — cpSync is called to copy uploads when they exist in the archive', async () => {
|
||||
setupSuccessfulExtraction();
|
||||
|
||||
const fakeDbInstance = {
|
||||
prepare: vi.fn()
|
||||
.mockReturnValueOnce({
|
||||
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
all: vi.fn().mockReturnValue([
|
||||
{ name: 'users' },
|
||||
{ name: 'trips' },
|
||||
{ name: 'trip_members' },
|
||||
{ name: 'places' },
|
||||
{ name: 'days' },
|
||||
]),
|
||||
}),
|
||||
close: vi.fn(),
|
||||
};
|
||||
DatabaseMock.mockReturnValue(fakeDbInstance);
|
||||
|
||||
fsMock.existsSync.mockImplementation((p: string) => {
|
||||
// travel.db present, extractedUploads present
|
||||
if (String(p).endsWith('travel.db')) return true;
|
||||
if (String(p).includes('uploads')) return true;
|
||||
return true;
|
||||
});
|
||||
fsMock.readdirSync.mockImplementation((p: string) => {
|
||||
// uploadsDir has one subdirectory 'photos'; 'photos' has one file
|
||||
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
|
||||
return ['photos'] as any;
|
||||
}
|
||||
if (String(p).includes('photos')) return ['img1.jpg'] as any;
|
||||
return [] as any;
|
||||
});
|
||||
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
|
||||
fsMock.unlinkSync.mockReturnValue(undefined);
|
||||
fsMock.copyFileSync.mockReturnValue(undefined);
|
||||
fsMock.cpSync.mockReturnValue(undefined);
|
||||
fsMock.rmSync.mockReturnValue(undefined);
|
||||
|
||||
await restoreFromZip('/data/tmp/upload.zip');
|
||||
|
||||
expect(fsMock.cpSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('uploads'),
|
||||
expect.stringContaining('uploads'),
|
||||
{ recursive: true, force: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateAutoSettings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BACKUP-047 updateAutoSettings', () => {
|
||||
let schedulerMock: typeof import('../../../src/scheduler');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
schedulerMock = await import('../../../src/scheduler');
|
||||
});
|
||||
|
||||
it('BACKUP-047a — calls scheduler.saveSettings with the parsed settings', () => {
|
||||
updateAutoSettings({ enabled: true, interval: 'weekly', hour: 6 });
|
||||
|
||||
expect(schedulerMock.saveSettings).toHaveBeenCalledOnce();
|
||||
expect(schedulerMock.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 })
|
||||
);
|
||||
});
|
||||
|
||||
it('BACKUP-047b — calls scheduler.start() after saving', () => {
|
||||
const saveOrder: string[] = [];
|
||||
(schedulerMock.saveSettings as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
saveOrder.push('saveSettings');
|
||||
});
|
||||
(schedulerMock.start as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
saveOrder.push('start');
|
||||
});
|
||||
|
||||
updateAutoSettings({ enabled: false });
|
||||
|
||||
expect(saveOrder).toEqual(['saveSettings', 'start']);
|
||||
});
|
||||
|
||||
it('BACKUP-047c — returns the parsed settings object', () => {
|
||||
const result = updateAutoSettings({
|
||||
enabled: true,
|
||||
interval: 'monthly',
|
||||
keep_days: 30,
|
||||
hour: 3,
|
||||
day_of_week: 2,
|
||||
day_of_month: 15,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
interval: 'monthly',
|
||||
keep_days: 30,
|
||||
hour: 3,
|
||||
day_of_week: 2,
|
||||
day_of_month: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ const mockDb = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
|
||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -65,22 +65,6 @@ beforeEach(() => {
|
||||
setupDb([], []);
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateSettlement', () => {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Unit tests for categoryService — CAT-SVC-001 through CAT-SVC-015.
|
||||
* 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 } from '../../helpers/factories';
|
||||
import {
|
||||
listCategories,
|
||||
createCategory,
|
||||
getCategoryById,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from '../../../src/services/categoryService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── listCategories ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listCategories', () => {
|
||||
it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => {
|
||||
// Migrations seed default categories, so the list is never empty in a fully initialized DB
|
||||
const cats = listCategories() as any[];
|
||||
expect(Array.isArray(cats)).toBe(true);
|
||||
expect(cats.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createCategory(user.id, 'Zoo');
|
||||
createCategory(user.id, 'Aquarium');
|
||||
// Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order
|
||||
const names = (listCategories() as any[]).map((c: any) => c.name);
|
||||
const aquariumIdx = names.indexOf('Aquarium');
|
||||
const zooIdx = names.indexOf('Zoo');
|
||||
expect(aquariumIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(zooIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(aquariumIdx).toBeLessThan(zooIdx);
|
||||
});
|
||||
|
||||
it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const before = (listCategories() as any[]).length;
|
||||
createCategory(a.id, 'Cat-A');
|
||||
createCategory(b.id, 'Cat-B');
|
||||
expect(listCategories()).toHaveLength(before + 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createCategory ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createCategory', () => {
|
||||
it('CAT-SVC-004 — creates a category with name, color, and icon', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'Restaurant', '#ff5500', '🍽️') as any;
|
||||
expect(cat.name).toBe('Restaurant');
|
||||
expect(cat.color).toBe('#ff5500');
|
||||
expect(cat.icon).toBe('🍽️');
|
||||
expect(cat.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'Default Color') as any;
|
||||
expect(cat.color).toBe('#6366f1');
|
||||
});
|
||||
|
||||
it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'Default Icon') as any;
|
||||
expect(cat.icon).toBe('📍');
|
||||
});
|
||||
|
||||
it('CAT-SVC-007 — returns the inserted row with an id', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'WithId') as any;
|
||||
expect(typeof cat.id).toBe('number');
|
||||
expect(cat.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getCategoryById ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('getCategoryById', () => {
|
||||
it('CAT-SVC-008 — returns category for a valid id', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createCategory(user.id, 'Find Me') as any;
|
||||
const found = getCategoryById(created.id) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.name).toBe('Find Me');
|
||||
});
|
||||
|
||||
it('CAT-SVC-009 — returns undefined for non-existent id', () => {
|
||||
expect(getCategoryById(99999)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createCategory(user.id, 'StringId') as any;
|
||||
const found = getCategoryById(String(created.id)) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateCategory ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateCategory', () => {
|
||||
it('CAT-SVC-011 — updates name, color, and icon', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'Old', '#aaaaaa', '❓') as any;
|
||||
const updated = updateCategory(cat.id, 'New', '#bbbbbb', '✅') as any;
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.color).toBe('#bbbbbb');
|
||||
expect(updated.icon).toBe('✅');
|
||||
});
|
||||
|
||||
it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'KeepName', '#aaaaaa', '⭐') as any;
|
||||
const updated = updateCategory(cat.id, undefined, '#cccccc', '🔥') as any;
|
||||
expect(updated.name).toBe('KeepName');
|
||||
expect(updated.color).toBe('#cccccc');
|
||||
});
|
||||
|
||||
it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'KeepColor', '#dddddd', '⭐') as any;
|
||||
const updated = updateCategory(cat.id, 'NewName', undefined, '🌟') as any;
|
||||
expect(updated.name).toBe('NewName');
|
||||
expect(updated.color).toBe('#dddddd');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteCategory ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteCategory', () => {
|
||||
it('CAT-SVC-014 — deletes the category from the database', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const cat = createCategory(user.id, 'ToDelete') as any;
|
||||
deleteCategory(cat.id);
|
||||
expect(getCategoryById(cat.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CAT-SVC-015 — deleting a non-existent category does not throw', () => {
|
||||
expect(() => deleteCategory(99999)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Unit tests for dayService — DAY-SVC-001 through DAY-SVC-030.
|
||||
* 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: (placeId: any) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-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, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
getAssignmentsForDay,
|
||||
listDays,
|
||||
createDay as svcCreateDay,
|
||||
getDay,
|
||||
updateDay,
|
||||
deleteDay,
|
||||
listAccommodations,
|
||||
validateAccommodationRefs,
|
||||
createAccommodation,
|
||||
getAccommodation,
|
||||
updateAccommodation,
|
||||
deleteAccommodation,
|
||||
} from '../../../src/services/dayService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── verifyTripAccess ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
it('DAY-SVC-001 — returns trip row for owner', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = verifyTripAccess(trip.id, user.id) as any;
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('DAY-SVC-002 — returns falsy for non-member', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
|
||||
|
||||
describe('getAssignmentsForDay', () => {
|
||||
it('DAY-SVC-003 — returns empty array when day has no assignments', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
expect(getAssignmentsForDay(day.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-004 — returns assignments with nested place object', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8, lng: 2.3 }) as any;
|
||||
createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
|
||||
|
||||
const assignments = getAssignmentsForDay(day.id) as any[];
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments[0].place).toBeDefined();
|
||||
expect(assignments[0].place.name).toBe('Eiffel Tower');
|
||||
expect(assignments[0].place.lat).toBe(48.8);
|
||||
});
|
||||
|
||||
it('DAY-SVC-005 — assignment includes tags array (empty when place has none)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'No Tags' }) as any;
|
||||
createDayAssignment(testDb, day.id, place.id);
|
||||
|
||||
const assignments = getAssignmentsForDay(day.id) as any[];
|
||||
expect(Array.isArray(assignments[0].place.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it('DAY-SVC-006 — assignments are ordered by order_index ASC', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const p1 = createPlace(testDb, trip.id, { name: 'Second' }) as any;
|
||||
const p2 = createPlace(testDb, trip.id, { name: 'First' }) as any;
|
||||
createDayAssignment(testDb, day.id, p1.id, { order_index: 2 });
|
||||
createDayAssignment(testDb, day.id, p2.id, { order_index: 1 });
|
||||
|
||||
const assignments = getAssignmentsForDay(day.id) as any[];
|
||||
expect(assignments[0].place.name).toBe('First');
|
||||
expect(assignments[1].place.name).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
// ── listDays ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listDays', () => {
|
||||
it('DAY-SVC-007 — returns { days: [] } for trip with no days', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = listDays(trip.id) as any;
|
||||
expect(result.days).toEqual([]);
|
||||
});
|
||||
|
||||
it('DAY-SVC-008 — returns days with assignments nested', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id);
|
||||
const result = listDays(trip.id) as any;
|
||||
expect(result.days).toHaveLength(1);
|
||||
expect(Array.isArray(result.days[0].assignments)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createDay ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createDay (service)', () => {
|
||||
it('DAY-SVC-009 — creates a day with auto-incremented day_number', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const d1 = svcCreateDay(trip.id) as any;
|
||||
const d2 = svcCreateDay(trip.id) as any;
|
||||
expect(d1.day_number).toBe(1);
|
||||
expect(d2.day_number).toBe(2);
|
||||
});
|
||||
|
||||
it('DAY-SVC-010 — returns day with empty assignments array', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = svcCreateDay(trip.id) as any;
|
||||
expect(Array.isArray(day.assignments)).toBe(true);
|
||||
expect(day.assignments).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getDay / updateDay / deleteDay ────────────────────────────────────────────
|
||||
|
||||
describe('getDay', () => {
|
||||
it('DAY-SVC-011 — returns day when id and tripId match', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const found = getDay(day.id, trip.id) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.id).toBe(day.id);
|
||||
});
|
||||
|
||||
it('DAY-SVC-012 — returns undefined for non-existent day', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(getDay(99999, trip.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDay', () => {
|
||||
it('DAY-SVC-013 — updates notes and returns updated day with assignments', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const updated = updateDay(day.id, day, { notes: 'Updated notes' }) as any;
|
||||
expect(updated.notes).toBe('Updated notes');
|
||||
expect(Array.isArray(updated.assignments)).toBe(true);
|
||||
});
|
||||
|
||||
it('DAY-SVC-014 — updates title', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const updated = updateDay(day.id, day, { title: 'Day 1 - City Tour' }) as any;
|
||||
expect(updated.title).toBe('Day 1 - City Tour');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDay', () => {
|
||||
it('DAY-SVC-015 — deletes the day', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
deleteDay(day.id);
|
||||
expect(getDay(day.id, trip.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateAccommodationRefs ─────────────────────────────────────────────────
|
||||
|
||||
describe('validateAccommodationRefs', () => {
|
||||
it('DAY-SVC-016 — returns no errors when all refs are valid', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const errors = validateAccommodationRefs(trip.id, place.id, day.id, day.id);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DAY-SVC-017 — returns error when place does not exist in trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const errors = validateAccommodationRefs(trip.id, 99999, day.id, day.id);
|
||||
expect(errors.some((e: any) => e.field === 'place_id')).toBe(true);
|
||||
});
|
||||
|
||||
it('DAY-SVC-018 — returns error when start_day_id is invalid', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const errors = validateAccommodationRefs(trip.id, place.id, 99999, day.id);
|
||||
expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAccommodation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('createAccommodation', () => {
|
||||
it('DAY-SVC-019 — creates accommodation and returns it with place info', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any;
|
||||
|
||||
const accom = createAccommodation(trip.id, {
|
||||
place_id: place.id,
|
||||
start_day_id: day.id,
|
||||
end_day_id: day.id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
}) as any;
|
||||
|
||||
expect(accom).toBeDefined();
|
||||
expect(accom.place_name).toBe('Grand Hotel');
|
||||
});
|
||||
|
||||
it('DAY-SVC-020 — auto-creates a linked reservation', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any;
|
||||
|
||||
const accom = createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
}) as any;
|
||||
|
||||
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
expect(reservation.type).toBe('hotel');
|
||||
expect(reservation.status).toBe('confirmed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAccommodation ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAccommodation', () => {
|
||||
it('DAY-SVC-021 — returns accommodation for valid id and trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
|
||||
const found = getAccommodation(accom.id, trip.id) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.id).toBe(accom.id);
|
||||
});
|
||||
|
||||
it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(getAccommodation(99999, trip.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAccommodation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('updateAccommodation', () => {
|
||||
it('DAY-SVC-023 — updates check-in and check-out times', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const accom = createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
}) as any;
|
||||
|
||||
const existing = getAccommodation(accom.id, trip.id)!;
|
||||
const updated = updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any;
|
||||
expect(updated).toBeDefined();
|
||||
|
||||
// Verify linked reservation metadata was synced
|
||||
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
expect(reservation).toBeDefined();
|
||||
const meta = JSON.parse(reservation.metadata || '{}');
|
||||
expect(meta.check_in_time).toBe('16:00');
|
||||
expect(meta.check_out_time).toBe('12:00');
|
||||
});
|
||||
|
||||
it('DAY-SVC-024 — preserves existing fields when not updated', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const accom = createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
confirmation: 'ABC123',
|
||||
}) as any;
|
||||
|
||||
const existing = getAccommodation(accom.id, trip.id)!;
|
||||
updateAccommodation(accom.id, existing as any, { check_in: '14:00' });
|
||||
|
||||
const row = getAccommodation(accom.id, trip.id) as any;
|
||||
expect(row.confirmation).toBe('ABC123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteAccommodation ───────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteAccommodation', () => {
|
||||
it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const accom = createAccommodation(trip.id, {
|
||||
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
|
||||
}) as any;
|
||||
|
||||
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
|
||||
|
||||
const result = deleteAccommodation(accom.id);
|
||||
expect(result.linkedReservationId).toBe(reservation.id);
|
||||
|
||||
// Accommodation is gone
|
||||
expect(getAccommodation(accom.id, trip.id)).toBeUndefined();
|
||||
|
||||
// Reservation is gone
|
||||
const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
|
||||
expect(deletedRes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
|
||||
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
|
||||
|
||||
// Remove the auto-created reservation so there's no linked one
|
||||
testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id);
|
||||
|
||||
const result = deleteAccommodation(accom.id);
|
||||
expect(result.linkedReservationId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -68,4 +68,49 @@ describe('ephemeralTokens', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startTokenCleanup / stopTokenCleanup', () => {
|
||||
it('startTokenCleanup starts the interval (second call is no-op)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
|
||||
startTokenCleanup();
|
||||
startTokenCleanup(); // should be no-op, not throw
|
||||
// Token created while cleanup is running should still be consumable (interval hasn't fired)
|
||||
const token = createEphemeralToken(1, 'ws')!;
|
||||
expect(consumeEphemeralToken(token, 'ws')).toBe(1);
|
||||
stopTokenCleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stopTokenCleanup clears the interval and allows restart', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
|
||||
startTokenCleanup();
|
||||
stopTokenCleanup();
|
||||
stopTokenCleanup(); // calling stop twice should not throw
|
||||
startTokenCleanup(); // should be able to start again after stop
|
||||
stopTokenCleanup();
|
||||
// After stop, tokens should still be consumable (cleanup didn't run)
|
||||
const token = createEphemeralToken(2, 'download')!;
|
||||
expect(consumeEphemeralToken(token, 'download')).toBe(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('cleanup interval removes expired tokens', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
|
||||
startTokenCleanup();
|
||||
const token = createEphemeralToken(1, 'ws')!; // 30s TTL
|
||||
|
||||
// Advance past TTL AND past cleanup interval (60s)
|
||||
vi.advanceTimersByTime(65_000);
|
||||
|
||||
// Token should have been cleaned up by the interval
|
||||
const result = consumeEphemeralToken(token, 'ws');
|
||||
expect(result).toBeNull();
|
||||
|
||||
stopTokenCleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Unit tests for inAppNotificationActions — NOTIF-ACT-001 through NOTIF-ACT-008.
|
||||
* Pure Map registry — no DB or external dependencies.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAction } from '../../../src/services/inAppNotificationActions';
|
||||
|
||||
describe('getAction — built-in registrations', () => {
|
||||
it('NOTIF-ACT-001 — test_approve is pre-registered', () => {
|
||||
const handler = getAction('test_approve');
|
||||
expect(handler).toBeDefined();
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
|
||||
it('NOTIF-ACT-002 — test_deny is pre-registered', () => {
|
||||
const handler = getAction('test_deny');
|
||||
expect(handler).toBeDefined();
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import {
|
||||
getAdminGlobalPref,
|
||||
getActiveChannels,
|
||||
getAvailableChannels,
|
||||
isWebhookConfigured,
|
||||
} from '../../../src/services/notificationPreferencesService';
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -316,3 +317,19 @@ describe('setAdminPreferences', () => {
|
||||
expect(row?.value).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// isWebhookConfigured
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isWebhookConfigured', () => {
|
||||
it('NPREF-026 — returns false when webhook is not in active channels', () => {
|
||||
// No notification_channels configured → defaults don't include webhook
|
||||
expect(isWebhookConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('NPREF-027 — returns true when webhook is in active channels', () => {
|
||||
setNotificationChannels(testDb, 'webhook');
|
||||
expect(isWebhookConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Unit tests for oidcService — OIDC-SVC-001 through OIDC-SVC-025.
|
||||
* Covers state management, auth codes, role resolution, findOrCreateUser,
|
||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } 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: (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: () => {},
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import {
|
||||
createState,
|
||||
consumeState,
|
||||
createAuthCode,
|
||||
consumeAuthCode,
|
||||
resolveOidcRole,
|
||||
frontendUrl,
|
||||
findOrCreateUser,
|
||||
discover,
|
||||
} from '../../../src/services/oidcService';
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
issuer: 'https://oidc.example.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
displayName: 'SSO',
|
||||
discoveryUrl: null,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
delete process.env.OIDC_ADMIN_VALUE;
|
||||
delete process.env.OIDC_ADMIN_CLAIM;
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── createState / consumeState ────────────────────────────────────────────────
|
||||
|
||||
describe('createState / consumeState', () => {
|
||||
it('OIDC-SVC-001: createState returns a hex token', () => {
|
||||
const state = createState('https://example.com/callback');
|
||||
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => {
|
||||
const state = createState('https://example.com/callback', 'invite-abc');
|
||||
const data = consumeState(state);
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.redirectUri).toBe('https://example.com/callback');
|
||||
expect(data!.inviteToken).toBe('invite-abc');
|
||||
// State is consumed — second call returns null
|
||||
expect(consumeState(state)).toBeNull();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
|
||||
expect(consumeState('not-a-real-state')).toBeNull();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-004: two different states do not conflict', () => {
|
||||
const s1 = createState('http://a.example.com');
|
||||
const s2 = createState('http://b.example.com');
|
||||
expect(s1).not.toBe(s2);
|
||||
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
|
||||
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
|
||||
|
||||
describe('createAuthCode / consumeAuthCode', () => {
|
||||
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
|
||||
const code = createAuthCode('my.jwt.token');
|
||||
expect(typeof code).toBe('string');
|
||||
expect(code.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
|
||||
const code = createAuthCode('real.jwt.here');
|
||||
const result = consumeAuthCode(code);
|
||||
expect('token' in result).toBe(true);
|
||||
expect((result as { token: string }).token).toBe('real.jwt.here');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
|
||||
const code = createAuthCode('single.use.token');
|
||||
consumeAuthCode(code); // first use
|
||||
const second = consumeAuthCode(code);
|
||||
expect('error' in second).toBe(true);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
|
||||
const result = consumeAuthCode('not-a-real-code');
|
||||
expect('error' in result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveOidcRole ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveOidcRole', () => {
|
||||
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
|
||||
expect(resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
|
||||
delete process.env.OIDC_ADMIN_VALUE;
|
||||
expect(resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
|
||||
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
|
||||
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
|
||||
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
|
||||
process.env.OIDC_ADMIN_VALUE = 'superadmin';
|
||||
process.env.OIDC_ADMIN_CLAIM = 'roles';
|
||||
expect(resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-014: handles string claim (exact match)', () => {
|
||||
process.env.OIDC_ADMIN_VALUE = 'admin';
|
||||
process.env.OIDC_ADMIN_CLAIM = 'role';
|
||||
expect(resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
|
||||
expect(resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
// ── frontendUrl ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('frontendUrl', () => {
|
||||
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
expect(frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-016: returns bare path in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
expect(frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
|
||||
delete process.env.NODE_ENV;
|
||||
});
|
||||
});
|
||||
|
||||
// ── discover ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discover', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
|
||||
const doc = {
|
||||
authorization_endpoint: 'https://oidc.example.com/auth',
|
||||
token_endpoint: 'https://oidc.example.com/token',
|
||||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => doc,
|
||||
}));
|
||||
|
||||
// Use unique issuer to bypass module-level cache from other tests
|
||||
const result = await discover('https://unique-1.example.com');
|
||||
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
|
||||
expect(result.token_endpoint).toBe(doc.token_endpoint);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
|
||||
|
||||
describe('getOidcConfig issuer trailing-slash regex', () => {
|
||||
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
|
||||
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
|
||||
// Adversarial input: many trailing slashes — should not backtrack catastrophically
|
||||
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
|
||||
const start = Date.now();
|
||||
const result = adversarial.replace(/\/+$/, '');
|
||||
const elapsed = Date.now() - start;
|
||||
expect(result).toBe('https://oidc.example.com');
|
||||
expect(elapsed).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── findOrCreateUser ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('findOrCreateUser', () => {
|
||||
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
|
||||
const { user } = createUser(testDb, { email: 'alice@example.com' });
|
||||
// Link the sub manually
|
||||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||||
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
expect('user' in result).toBe(true);
|
||||
expect((result as { user: any }).user.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
|
||||
const { user } = createUser(testDb, { email: 'bob@example.com' });
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
expect('user' in result).toBe(true);
|
||||
expect((result as { user: any }).user.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-022: creates new user when registration is open', () => {
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
expect('user' in result).toBe(true);
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
|
||||
expect(newUser).toBeDefined();
|
||||
});
|
||||
|
||||
it('OIDC-SVC-023: first user gets admin role', () => {
|
||||
// DB is empty after resetTestDb
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
expect('user' in result).toBe(true);
|
||||
expect((result as { user: any }).user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
|
||||
createUser(testDb, { email: 'existing@example.com' });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
expect((result as { error: string }).error).toBe('registration_disabled');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-025: links oidc_sub when existing user has none', () => {
|
||||
const { user } = createUser(testDb, { email: 'charlie@example.com' });
|
||||
// Ensure no oidc_sub set
|
||||
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
|
||||
|
||||
findOrCreateUser(
|
||||
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie' },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
|
||||
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(updated.oidc_sub).toBe('sub-charlie-linked');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
|
||||
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
|
||||
// Link oidc_sub manually so the user is found by sub lookup
|
||||
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
|
||||
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
|
||||
|
||||
process.env.OIDC_ADMIN_VALUE = 'admins';
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
|
||||
MOCK_CONFIG
|
||||
);
|
||||
|
||||
expect('user' in result).toBe(true);
|
||||
expect((result as { user: any }).user.role).toBe('admin');
|
||||
|
||||
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
|
||||
expect(dbUser.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
|
||||
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
|
||||
testDb.prepare(
|
||||
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
|
||||
).run(creator.id);
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
|
||||
MOCK_CONFIG,
|
||||
'tok-valid'
|
||||
);
|
||||
|
||||
expect('user' in result).toBe(true);
|
||||
|
||||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
|
||||
expect(token.used_count).toBe(1);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
|
||||
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
|
||||
testDb.prepare(
|
||||
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
|
||||
).run(creator.id);
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
|
||||
MOCK_CONFIG,
|
||||
'tok-expired'
|
||||
);
|
||||
|
||||
// User is still created because open registration is allowed
|
||||
expect('user' in result).toBe(true);
|
||||
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
|
||||
expect(newUser).toBeDefined();
|
||||
|
||||
// Invite used_count must remain 0 (token was treated as invalid)
|
||||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
|
||||
expect(token.used_count).toBe(0);
|
||||
});
|
||||
|
||||
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
|
||||
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
|
||||
testDb.prepare(
|
||||
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
|
||||
).run(creator.id);
|
||||
|
||||
const result = findOrCreateUser(
|
||||
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
|
||||
MOCK_CONFIG,
|
||||
'tok-full'
|
||||
);
|
||||
|
||||
// User is still created because open registration is allowed
|
||||
expect('user' in result).toBe(true);
|
||||
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
|
||||
expect(newUser).toBeDefined();
|
||||
|
||||
// Invite used_count must remain 1 (token was treated as invalid)
|
||||
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
|
||||
expect(token.used_count).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Unit tests for packingService.ts — uncovered functions.
|
||||
* Covers PACK-SVC-001 to PACK-SVC-012.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB mock setup (vi.hoisted so it is available before vi.mock calls) ────────
|
||||
|
||||
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 } from '../../helpers/factories';
|
||||
import {
|
||||
saveAsTemplate,
|
||||
applyTemplate,
|
||||
setBagMembers,
|
||||
createBag,
|
||||
deleteBag,
|
||||
bulkImport,
|
||||
} from '../../../src/services/packingService';
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── saveAsTemplate ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('saveAsTemplate', () => {
|
||||
it('PACK-SVC-001: saves packing items as a template with correct categories and item count', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0);
|
||||
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shorts', 'Clothes', 1);
|
||||
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 2);
|
||||
|
||||
const result = saveAsTemplate(trip.id, user.id, 'My Template');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.name).toBe('My Template');
|
||||
expect(result!.categoryCount).toBe(2);
|
||||
expect(result!.itemCount).toBe(3);
|
||||
|
||||
const template = testDb.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result!.id) as any;
|
||||
expect(template).toBeDefined();
|
||||
expect(template.name).toBe('My Template');
|
||||
expect(template.created_by).toBe(user.id);
|
||||
});
|
||||
|
||||
it('PACK-SVC-002: returns null when trip has no packing items', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = saveAsTemplate(trip.id, user.id, 'Empty');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyTemplate ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyTemplate', () => {
|
||||
it('PACK-SVC-003: adds template items to a trip packing list', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Insert a template with one category and two items directly
|
||||
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Camping', user.id);
|
||||
const templateId = templateResult.lastInsertRowid as number;
|
||||
|
||||
const catResult = testDb.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, 'Gear', 0);
|
||||
const catId = catResult.lastInsertRowid as number;
|
||||
|
||||
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Tent', 0);
|
||||
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Sleeping Bag', 1);
|
||||
|
||||
const result = applyTemplate(trip.id, templateId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as any[]).length).toBe(2);
|
||||
|
||||
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
|
||||
expect(items.length).toBe(2);
|
||||
expect(items.map((i: any) => i.name)).toContain('Tent');
|
||||
expect(items.map((i: any) => i.name)).toContain('Sleeping Bag');
|
||||
});
|
||||
|
||||
it('PACK-SVC-004: returns null when template has no items', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Empty Template', user.id);
|
||||
const templateId = templateResult.lastInsertRowid as number;
|
||||
|
||||
const result = applyTemplate(trip.id, templateId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── createBag / deleteBag ─────────────────────────────────────────────────────
|
||||
|
||||
describe('createBag / deleteBag', () => {
|
||||
it('PACK-SVC-005: createBag inserts a bag and returns it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = createBag(trip.id, { name: 'Carry-On', color: '#ff0000' }) as any;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.name).toBe('Carry-On');
|
||||
expect(result.color).toBe('#ff0000');
|
||||
expect(result.trip_id).toBe(trip.id);
|
||||
|
||||
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.id) as any;
|
||||
expect(bag).toBeDefined();
|
||||
expect(bag.name).toBe('Carry-On');
|
||||
});
|
||||
|
||||
it('PACK-SVC-006: deleteBag removes the bag and returns true', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const bag = createBag(trip.id, { name: 'Checked Bag' }) as any;
|
||||
expect(bag).not.toBeNull();
|
||||
|
||||
const deleted = deleteBag(trip.id, bag.id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bag.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PACK-SVC-007: deleteBag returns false for non-existent bag', () => {
|
||||
const result = deleteBag(1, 99999);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setBagMembers ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setBagMembers', () => {
|
||||
it('PACK-SVC-008: sets bag members (replaces existing)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
|
||||
|
||||
const result = setBagMembers(trip.id, bag.id, [user.id]) as any[];
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('PACK-SVC-009: setBagMembers with empty array clears all members', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
|
||||
|
||||
// First add a member
|
||||
setBagMembers(trip.id, bag.id, [user.id]);
|
||||
|
||||
// Then clear
|
||||
const result = setBagMembers(trip.id, bag.id, []) as any[];
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('PACK-SVC-010: setBagMembers returns null for non-existent bag', () => {
|
||||
const result = setBagMembers(1, 99999, []);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── bulkImport with bag field ─────────────────────────────────────────────────
|
||||
|
||||
describe('bulkImport with bag field', () => {
|
||||
it('PACK-SVC-011: bulk import with bag field creates the bag if it does not exist', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = bulkImport(trip.id, [{ name: 'Shirt', bag: 'Carry-On' }]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeDefined();
|
||||
|
||||
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
|
||||
expect(bags).toHaveLength(1);
|
||||
|
||||
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].bag_id).toBe(bags[0].id);
|
||||
});
|
||||
|
||||
it('PACK-SVC-012: bulk import with same bag name reuses existing bag', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = bulkImport(trip.id, [
|
||||
{ name: 'Shirt', bag: 'Carry-On' },
|
||||
{ name: 'Pants', bag: 'Carry-On' },
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
|
||||
expect(bags).toHaveLength(1);
|
||||
|
||||
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].bag_id).toBe(bags[0].id);
|
||||
expect(items[1].bag_id).toBe(bags[0].id);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,21 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mutable rows array so individual tests can inject DB rows
|
||||
const dbRows: { key: string; value: string }[] = [];
|
||||
|
||||
// Mock database — permissions module queries app_settings at runtime
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: () => ({
|
||||
all: () => [], // no custom permissions → fall back to defaults
|
||||
all: () => dbRows, // no custom permissions → fall back to defaults
|
||||
run: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}),
|
||||
transaction: (fn: () => void) => fn,
|
||||
},
|
||||
}));
|
||||
|
||||
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
|
||||
import { checkPermission, getPermissionLevel, savePermissions, invalidatePermissionsCache, PERMISSION_ACTIONS } from '../../../src/services/permissions';
|
||||
|
||||
describe('permissions', () => {
|
||||
describe('checkPermission — admin bypass', () => {
|
||||
@@ -80,4 +85,30 @@ describe('permissions', () => {
|
||||
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePermissions — invalid action key is skipped', () => {
|
||||
it('returns skipped array containing invalid action key', () => {
|
||||
const result = savePermissions({ nonexistent_action: 'trip_member' });
|
||||
expect(result.skipped).toContain('nonexistent_action');
|
||||
});
|
||||
|
||||
it('returns skipped array when level is not in allowedLevels for the action', () => {
|
||||
// trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid
|
||||
const result = savePermissions({ trip_delete: 'trip_member' });
|
||||
expect(result.skipped).toContain('trip_delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission — default case', () => {
|
||||
it('returns false when permission level is an unrecognized value', () => {
|
||||
// Inject a DB row with an unknown level for trip_edit, then invalidate cache
|
||||
dbRows.push({ key: 'perm_trip_edit', value: 'unknown_level' });
|
||||
invalidatePermissionsCache();
|
||||
const result = checkPermission('trip_edit', 'user', 10, 10, false);
|
||||
// Clean up for subsequent tests
|
||||
dbRows.length = 0;
|
||||
invalidatePermissionsCache();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Unit tests for placeService — PLACE-SVC-001 through PLACE-SVC-025.
|
||||
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
||||
* Skips importGpx / importGoogleList / searchPlaceImage (require external I/O).
|
||||
*/
|
||||
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: (placeId: any) => {
|
||||
const place: any = db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
|
||||
`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-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, createPlace, createCategory, createTag } from '../../helpers/factories';
|
||||
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── listPlaces ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listPlaces', () => {
|
||||
it('PLACE-SVC-001 — returns empty array when trip has no places', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(listPlaces(String(trip.id), {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-002 — returns all places for a trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'Alpha' });
|
||||
createPlace(testDb, trip.id, { name: 'Beta' });
|
||||
const places = listPlaces(String(trip.id), {}) as any[];
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-003 — does not return places from other trips', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const t1 = createTrip(testDb, user.id);
|
||||
const t2 = createTrip(testDb, user.id);
|
||||
createPlace(testDb, t1.id, { name: 'T1 Place' });
|
||||
createPlace(testDb, t2.id, { name: 'T2 Place' });
|
||||
const places = listPlaces(String(t1.id), {}) as any[];
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].name).toBe('T1 Place');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-004 — filters by search term (name)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
createPlace(testDb, trip.id, { name: 'Louvre Museum' });
|
||||
const places = listPlaces(String(trip.id), { search: 'Eiffel' }) as any[];
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].name).toBe('Eiffel Tower');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPlace(testDb, trip.id, { name: 'No Tags' });
|
||||
const places = listPlaces(String(trip.id), {}) as any[];
|
||||
expect(Array.isArray(places[0].tags)).toBe(true);
|
||||
expect(places[0].tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-006 — attaches category object when place has a category', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any;
|
||||
testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id);
|
||||
|
||||
const places = listPlaces(String(trip.id), {}) as any[];
|
||||
expect(places[0].category).toBeDefined();
|
||||
expect(places[0].category.name).toBe('Museum');
|
||||
});
|
||||
});
|
||||
|
||||
// ── createPlace (via service) ─────────────────────────────────────────────────
|
||||
|
||||
describe('createPlace (service)', () => {
|
||||
it('PLACE-SVC-007 — creates a place and returns it with tags array', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = svcCreatePlace(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any;
|
||||
expect(place).toBeDefined();
|
||||
expect(place.name).toBe('New Place');
|
||||
expect(Array.isArray(place.tags)).toBe(true);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-008 — creates a place with tags', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any;
|
||||
const place = svcCreatePlace(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any;
|
||||
expect(place.tags).toHaveLength(1);
|
||||
expect(place.tags[0].id).toBe(tag.id);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-009 — place is associated with correct trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = svcCreatePlace(String(trip.id), { name: 'My Place' }) as any;
|
||||
const row = testDb.prepare('SELECT trip_id FROM places WHERE id = ?').get(place.id) as any;
|
||||
expect(row.trip_id).toBe(trip.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPlace ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getPlace', () => {
|
||||
it('PLACE-SVC-010 — returns the place when tripId and placeId match', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any;
|
||||
const found = getPlace(String(trip.id), String(place.id)) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.name).toBe('Find Me');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-011 — returns null when place belongs to different trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const t1 = createTrip(testDb, user.id);
|
||||
const t2 = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any;
|
||||
expect(getPlace(String(t2.id), String(place.id))).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-012 — returns null for non-existent placeId', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(getPlace(String(trip.id), '99999')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePlace ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updatePlace', () => {
|
||||
it('PLACE-SVC-013 — updates place name and lat/lng', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any;
|
||||
const updated = updatePlace(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any;
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.lat).toBe(48.8);
|
||||
expect(updated.lng).toBe(2.3);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-014 — returns null for non-existent place', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(updatePlace(String(trip.id), '99999', { name: 'Ghost' })).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-015 — updates tags (replaces old set)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any;
|
||||
const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any;
|
||||
const place = svcCreatePlace(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any;
|
||||
|
||||
const updated = updatePlace(String(trip.id), String(place.id), { tags: [tag2.id] }) as any;
|
||||
expect(updated.tags).toHaveLength(1);
|
||||
expect(updated.tags[0].id).toBe(tag2.id);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-016 — clears tags when tags: [] is passed', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const tag = createTag(testDb, user.id, { name: 'Temp' }) as any;
|
||||
const place = svcCreatePlace(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any;
|
||||
|
||||
const updated = updatePlace(String(trip.id), String(place.id), { tags: [] }) as any;
|
||||
expect(updated.tags).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deletePlace ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deletePlace', () => {
|
||||
it('PLACE-SVC-017 — deletes a place and returns true', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any;
|
||||
expect(deletePlace(String(trip.id), String(place.id))).toBe(true);
|
||||
expect(getPlace(String(trip.id), String(place.id))).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-018 — returns false for non-existent place', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(deletePlace(String(trip.id), '99999')).toBe(false);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-019 — deleting one place does not remove others', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any;
|
||||
const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any;
|
||||
deletePlace(String(trip.id), String(p2.id));
|
||||
const remaining = listPlaces(String(trip.id), {}) as any[];
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe(p1.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── importGpx ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('importGpx', () => {
|
||||
it('PLACE-SVC-020 — returns null when buffer has no <gpx> root', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = importGpx(String(trip.id), Buffer.from('<not-gpx/>'));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-021 — imports <wpt> waypoints as places', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
|
||||
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
|
||||
</gpx>`);
|
||||
const places = importGpx(String(trip.id), gpx) as any[];
|
||||
expect(places).toHaveLength(2);
|
||||
expect(places[0].name).toBe('Paris');
|
||||
expect(places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<rte>
|
||||
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
|
||||
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
||||
</rte>
|
||||
</gpx>`);
|
||||
const places = importGpx(String(trip.id), gpx) as any[];
|
||||
expect(places).toHaveLength(2);
|
||||
expect(places[0].name).toBe('Start');
|
||||
expect(places[1].name).toBe('End');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<trk>
|
||||
<name>My Track</name>
|
||||
<trkseg>
|
||||
<trkpt lat="48.8566" lon="2.3522"><ele>100</ele></trkpt>
|
||||
<trkpt lat="48.8570" lon="2.3530"><ele>102</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
const places = importGpx(String(trip.id), gpx) as any[];
|
||||
expect(places).toHaveLength(1);
|
||||
expect(places[0].name).toBe('My Track');
|
||||
const geometry = JSON.parse(places[0].route_geometry);
|
||||
expect(Array.isArray(geometry)).toBe(true);
|
||||
expect(geometry).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-024 — <wpt> and <trk> together: waypoints plus track appended', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<wpt lat="48.8566" lon="2.3522"><name>POI</name></wpt>
|
||||
<trk>
|
||||
<name>Track</name>
|
||||
<trkseg>
|
||||
<trkpt lat="48.8566" lon="2.3522"></trkpt>
|
||||
<trkpt lat="48.8570" lon="2.3530"></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
const places = importGpx(String(trip.id), gpx) as any[];
|
||||
// 1 wpt + 1 trk
|
||||
expect(places).toHaveLength(2);
|
||||
const trackPlace = places.find((p: any) => p.name === 'Track') as any;
|
||||
expect(trackPlace).toBeDefined();
|
||||
const geometry = JSON.parse(trackPlace.route_geometry);
|
||||
expect(geometry).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-025 — returns null when GPX has no usable elements', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1"></gpx>`);
|
||||
const result = importGpx(String(trip.id), gpx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── importGoogleList ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('importGoogleList', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any;
|
||||
expect(result.error).toMatch(/Could not extract list ID/);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.error).toMatch(/Failed to fetch list/);
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const listPayload = [
|
||||
[null, null, null, null, 'My Test List', null, null, null, [
|
||||
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
|
||||
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
|
||||
]],
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.listName).toBe('My Test List');
|
||||
expect(result.places).toHaveLength(2);
|
||||
expect(result.places[0].name).toBe('Paris');
|
||||
expect(result.places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'prefix\n' + JSON.stringify(listPayload),
|
||||
}));
|
||||
|
||||
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
|
||||
const result = await importGoogleList(String(trip.id), url) as any;
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchPlaceImage ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('searchPlaceImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('PLACE-SVC-030 — returns 404 when place does not exist', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = await searchPlaceImage(String(trip.id), '99999', user.id) as any;
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-031 — returns 400 when user has no Unsplash API key', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
||||
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
|
||||
expect(result.error).toMatch(/No Unsplash API key/);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
|
||||
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id);
|
||||
|
||||
const mockPhotos = [
|
||||
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
|
||||
];
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: mockPhotos }),
|
||||
status: 200,
|
||||
}));
|
||||
|
||||
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
|
||||
expect(result.photos).toHaveLength(1);
|
||||
expect(result.photos[0].id).toBe('photo1');
|
||||
expect(result.photos[0].url).toBe('https://img.example.com/1');
|
||||
expect(result.photos[0].photographer).toBe('Photographer');
|
||||
});
|
||||
});
|
||||
@@ -48,17 +48,6 @@ const sampleParticipants: Participant[] = [
|
||||
];
|
||||
|
||||
describe('formatAssignmentWithPlace', () => {
|
||||
it('returns correct top-level shape', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||
expect(result).toHaveProperty('id', 1);
|
||||
expect(result).toHaveProperty('day_id', 10);
|
||||
expect(result).toHaveProperty('order_index', 0);
|
||||
expect(result).toHaveProperty('notes', 'assignment note');
|
||||
expect(result).toHaveProperty('created_at');
|
||||
expect(result).toHaveProperty('place');
|
||||
expect(result).toHaveProperty('participants');
|
||||
});
|
||||
|
||||
it('nests place fields correctly from flat row', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
const { place } = result;
|
||||
@@ -100,24 +89,4 @@ describe('formatAssignmentWithPlace', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('includes provided tags in place.tags', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, []);
|
||||
expect(result.place.tags).toEqual(sampleTags);
|
||||
});
|
||||
|
||||
it('defaults place.tags to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.place.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes provided participants', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants);
|
||||
expect(result.participants).toEqual(sampleParticipants);
|
||||
});
|
||||
|
||||
it('defaults participants to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Unit tests for settingsService — SET-SVC-001 through SET-SVC-020.
|
||||
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
|
||||
* so we don't need real encryption for most tests.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB + apiKeyCrypto mock ────────────────────────────────────────────────────
|
||||
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
// Passthrough crypto — value comes back unchanged for most tests
|
||||
vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
maybe_encrypt_api_key: (v: string) => v,
|
||||
}));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { getUserSettings, upsertSetting, bulkUpsertSettings } from '../../../src/services/settingsService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── getUserSettings ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('getUserSettings', () => {
|
||||
it('SET-SVC-001 — returns empty object when user has no settings', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(getUserSettings(user.id)).toEqual({});
|
||||
});
|
||||
|
||||
it('SET-SVC-002 — returns stored plain string values', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('SET-SVC-003 — JSON-parses values that are valid JSON', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.count).toBe(42);
|
||||
expect(s.flag).toBe(true);
|
||||
expect(s.obj).toEqual({ x: 1 });
|
||||
});
|
||||
|
||||
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.raw).toBe('not-json');
|
||||
});
|
||||
|
||||
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.webhook_url).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('SET-SVC-006 — webhook_url with empty value returns empty string', () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.webhook_url).toBe('');
|
||||
});
|
||||
|
||||
it('SET-SVC-007 — only returns settings for the requesting user', () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
|
||||
const s = getUserSettings(a.id);
|
||||
expect(s).toHaveProperty('key_a');
|
||||
expect(s).not.toHaveProperty('key_b');
|
||||
});
|
||||
});
|
||||
|
||||
// ── upsertSetting ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('upsertSetting', () => {
|
||||
it('SET-SVC-008 — inserts a new setting', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'language', 'en');
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.language).toBe('en');
|
||||
});
|
||||
|
||||
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'language', 'en');
|
||||
upsertSetting(user.id, 'language', 'fr');
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.language).toBe('fr');
|
||||
});
|
||||
|
||||
it('SET-SVC-010 — serializes object values as JSON', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
|
||||
expect(raw.value).toBe('{"dark":true,"size":14}');
|
||||
});
|
||||
|
||||
it('SET-SVC-011 — serializes boolean values as strings', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'notifications', true);
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
|
||||
expect(raw.value).toBe('true');
|
||||
});
|
||||
|
||||
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
|
||||
// With passthrough mock, value is stored as-is
|
||||
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
|
||||
expect(raw.value).toBe('https://hook.example.com');
|
||||
// But getUserSettings masks it
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.webhook_url).toBe('••••••••');
|
||||
});
|
||||
});
|
||||
|
||||
// ── bulkUpsertSettings ────────────────────────────────────────────────────────
|
||||
|
||||
describe('bulkUpsertSettings', () => {
|
||||
it('SET-SVC-013 — inserts multiple settings in one call', () => {
|
||||
const { user } = createUser(testDb);
|
||||
bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.a).toBe('alpha');
|
||||
expect(s.b).toBe('beta');
|
||||
expect(s.c).toBe('gamma');
|
||||
});
|
||||
|
||||
it('SET-SVC-014 — returns the count of settings processed', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const count = bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
upsertSetting(user.id, 'theme', 'light');
|
||||
bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
|
||||
const s = getUserSettings(user.id);
|
||||
expect(s.theme).toBe('dark');
|
||||
expect(s.lang).toBe('en');
|
||||
});
|
||||
|
||||
it('SET-SVC-016 — returns 0 for empty settings object', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const count = bulkUpsertSettings(user.id, {});
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('SET-SVC-017 — all changes are committed atomically (transaction)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
bulkUpsertSettings(user.id, { p: '1', q: '2' });
|
||||
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
|
||||
const keys = rows.map((r: any) => r.key);
|
||||
expect(keys).toContain('p');
|
||||
expect(keys).toContain('q');
|
||||
});
|
||||
|
||||
it('SET-SVC-018 — settings from different users do not interfere', () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
bulkUpsertSettings(a.id, { shared_key: 'from-a' });
|
||||
bulkUpsertSettings(b.id, { shared_key: 'from-b' });
|
||||
expect((getUserSettings(a.id) as any).shared_key).toBe('from-a');
|
||||
expect((getUserSettings(b.id) as any).shared_key).toBe('from-b');
|
||||
});
|
||||
|
||||
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const origPrepare = testDb.prepare.bind(testDb);
|
||||
let intercepted = false;
|
||||
vi.spyOn(testDb, 'prepare').mockImplementationOnce((sql: string) => {
|
||||
const stmt = origPrepare(sql);
|
||||
intercepted = true;
|
||||
return { run: () => { throw new Error('forced DB error'); } } as any;
|
||||
});
|
||||
expect(() => bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
|
||||
expect(intercepted).toBe(true);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Unit tests for tagService — TAG-SVC-001 through TAG-SVC-015.
|
||||
* 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 } from '../../helpers/factories';
|
||||
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── listTags ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('listTags', () => {
|
||||
it('TAG-SVC-001 — returns empty array when user has no tags', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(listTags(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TAG-SVC-002 — returns only tags belonging to the user', () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
createTag(a.id, 'A-Tag');
|
||||
createTag(b.id, 'B-Tag');
|
||||
const tags = listTags(a.id) as any[];
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0].name).toBe('A-Tag');
|
||||
});
|
||||
|
||||
it('TAG-SVC-003 — results are ordered by name ascending', () => {
|
||||
const { user } = createUser(testDb);
|
||||
createTag(user.id, 'Zebra');
|
||||
createTag(user.id, 'Apple');
|
||||
createTag(user.id, 'Mango');
|
||||
const names = (listTags(user.id) as any[]).map((t: any) => t.name);
|
||||
expect(names).toEqual(['Apple', 'Mango', 'Zebra']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createTag ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createTag', () => {
|
||||
it('TAG-SVC-004 — creates a tag with provided name and color', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'Beach', '#ff0000') as any;
|
||||
expect(tag.name).toBe('Beach');
|
||||
expect(tag.color).toBe('#ff0000');
|
||||
expect(tag.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('TAG-SVC-005 — defaults to #10b981 when no color provided', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'Default') as any;
|
||||
expect(tag.color).toBe('#10b981');
|
||||
});
|
||||
|
||||
it('TAG-SVC-006 — returns the inserted row with an id', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'WithId') as any;
|
||||
expect(typeof tag.id).toBe('number');
|
||||
expect(tag.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getTagByIdAndUser ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTagByIdAndUser', () => {
|
||||
it('TAG-SVC-007 — returns the tag when id and user_id match', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = createTag(user.id, 'Find Me') as any;
|
||||
const found = getTagByIdAndUser(created.id, user.id) as any;
|
||||
expect(found).toBeDefined();
|
||||
expect(found.name).toBe('Find Me');
|
||||
});
|
||||
|
||||
it('TAG-SVC-008 — returns undefined when tag belongs to different user', () => {
|
||||
const { user: a } = createUser(testDb);
|
||||
const { user: b } = createUser(testDb);
|
||||
const tag = createTag(a.id, 'Private') as any;
|
||||
expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TAG-SVC-009 — returns undefined for non-existent tag id', () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(getTagByIdAndUser(99999, user.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateTag ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateTag', () => {
|
||||
it('TAG-SVC-010 — updates both name and color', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'Old', '#aaaaaa') as any;
|
||||
const updated = updateTag(tag.id, 'New', '#bbbbbb') as any;
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.color).toBe('#bbbbbb');
|
||||
});
|
||||
|
||||
it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'KeepMe', '#aaaaaa') as any;
|
||||
const updated = updateTag(tag.id, undefined, '#cccccc') as any;
|
||||
expect(updated.name).toBe('KeepMe');
|
||||
expect(updated.color).toBe('#cccccc');
|
||||
});
|
||||
|
||||
it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'ColorKeep', '#dddddd') as any;
|
||||
const updated = updateTag(tag.id, 'NewName', undefined) as any;
|
||||
expect(updated.name).toBe('NewName');
|
||||
expect(updated.color).toBe('#dddddd');
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteTag ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteTag', () => {
|
||||
it('TAG-SVC-013 — deletes the tag from the database', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const tag = createTag(user.id, 'ToDelete') as any;
|
||||
deleteTag(tag.id);
|
||||
expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TAG-SVC-014 — deleting a non-existent tag does not throw', () => {
|
||||
expect(() => deleteTag(99999)).not.toThrow();
|
||||
});
|
||||
|
||||
it('TAG-SVC-015 — deleting one tag does not affect other tags', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const t1 = createTag(user.id, 'Keep') as any;
|
||||
const t2 = createTag(user.id, 'Remove') as any;
|
||||
deleteTag(t2.id);
|
||||
const remaining = listTags(user.id) as any[];
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).toBe(t1.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Unit tests for todoService — TODO-SVC-001 through TODO-SVC-020.
|
||||
* 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: (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-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, addTripMember } from '../../helpers/factories';
|
||||
import {
|
||||
verifyTripAccess,
|
||||
listItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
getCategoryAssignees,
|
||||
updateCategoryAssignees,
|
||||
reorderItems,
|
||||
} from '../../../src/services/todoService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── verifyTripAccess ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
it('TODO-SVC-001: returns trip for owner', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = verifyTripAccess(trip.id, user.id);
|
||||
expect(result).toBeDefined();
|
||||
expect((result as any).id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('TODO-SVC-002: returns null for non-member', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('TODO-SVC-003: returns trip for 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 = verifyTripAccess(trip.id, member.id);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── listItems / createItem ────────────────────────────────────────────────────
|
||||
|
||||
describe('listItems and createItem', () => {
|
||||
it('TODO-SVC-004: listItems returns empty array for new trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(listItems(trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TODO-SVC-005: createItem inserts a todo with name only', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createItem(trip.id, { name: 'Buy snacks' }) as any;
|
||||
expect(item).toBeDefined();
|
||||
expect(item.name).toBe('Buy snacks');
|
||||
expect(item.checked).toBe(0);
|
||||
expect(item.trip_id).toBe(trip.id);
|
||||
expect(item.sort_order).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-SVC-006: createItem assigns incrementing sort_order', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createItem(trip.id, { name: 'A' }) as any;
|
||||
const b = createItem(trip.id, { name: 'B' }) as any;
|
||||
expect(b.sort_order).toBe(a.sort_order + 1);
|
||||
});
|
||||
|
||||
it('TODO-SVC-007: createItem stores optional fields', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createItem(trip.id, {
|
||||
name: 'Pack bag',
|
||||
category: 'Prep',
|
||||
description: 'All the gear',
|
||||
priority: 3,
|
||||
}) as any;
|
||||
expect(item.category).toBe('Prep');
|
||||
expect(item.description).toBe('All the gear');
|
||||
expect(item.priority).toBe(3);
|
||||
});
|
||||
|
||||
it('TODO-SVC-008: listItems returns items ordered by sort_order', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createItem(trip.id, { name: 'First' });
|
||||
createItem(trip.id, { name: 'Second' });
|
||||
createItem(trip.id, { name: 'Third' });
|
||||
const items = listItems(trip.id) as any[];
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].sort_order).toBeLessThanOrEqual(items[1].sort_order);
|
||||
expect(items[1].sort_order).toBeLessThanOrEqual(items[2].sort_order);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateItem ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('TODO-SVC-009: returns null for non-existent item', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull();
|
||||
});
|
||||
|
||||
it('TODO-SVC-010: toggles checked status', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createItem(trip.id, { name: 'Visit museum' }) as any;
|
||||
const updated = updateItem(trip.id, item.id, { checked: 1 }, ['checked']) as any;
|
||||
expect(updated.checked).toBe(1);
|
||||
const back = updateItem(trip.id, item.id, { checked: 0 }, ['checked']) as any;
|
||||
expect(back.checked).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-SVC-011: updates name and category', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = createItem(trip.id, { name: 'Old' }) as any;
|
||||
const updated = updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category']) as any;
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.category).toBe('Misc');
|
||||
});
|
||||
|
||||
it('TODO-SVC-012: clears due_date when key is present with null value', () => {
|
||||
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 updated = updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any;
|
||||
expect(updated.due_date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteItem ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('TODO-SVC-013: returns false for non-existent item', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(deleteItem(trip.id, 99999)).toBe(false);
|
||||
});
|
||||
|
||||
it('TODO-SVC-014: deletes item and returns true', () => {
|
||||
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(listItems(trip.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── reorderItems ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reorderItems', () => {
|
||||
it('TODO-SVC-015: assigns sort_order matching orderedIds array position', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = createItem(trip.id, { name: 'A' }) as any;
|
||||
const b = createItem(trip.id, { name: 'B' }) as any;
|
||||
const c = createItem(trip.id, { name: 'C' }) as any;
|
||||
|
||||
reorderItems(trip.id, [c.id, a.id, b.id]);
|
||||
|
||||
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
|
||||
expect(rows[0].id).toBe(c.id);
|
||||
expect(rows[1].id).toBe(a.id);
|
||||
expect(rows[2].id).toBe(b.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── category assignees ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getCategoryAssignees / updateCategoryAssignees', () => {
|
||||
it('TODO-SVC-016: returns empty object for new trip', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(getCategoryAssignees(trip.id)).toEqual({});
|
||||
});
|
||||
|
||||
it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const rows = updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]) as any[];
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const assignees = getCategoryAssignees(trip.id) as any;
|
||||
expect(assignees['Packing']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
updateCategoryAssignees(trip.id, 'Packing', [owner.id]);
|
||||
const cleared = updateCategoryAssignees(trip.id, 'Packing', []) as any[];
|
||||
expect(cleared).toHaveLength(0);
|
||||
|
||||
const assignees = getCategoryAssignees(trip.id) as any;
|
||||
expect(assignees['Packing']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TODO-SVC-019: getCategoryAssignees groups by category name', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
updateCategoryAssignees(trip.id, 'Shopping', [owner.id]);
|
||||
updateCategoryAssignees(trip.id, 'Logistics', [member.id]);
|
||||
|
||||
const assignees = getCategoryAssignees(trip.id) as any;
|
||||
expect(Object.keys(assignees)).toHaveLength(2);
|
||||
expect(assignees['Shopping']).toHaveLength(1);
|
||||
expect(assignees['Logistics']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]);
|
||||
// Replace with just owner
|
||||
updateCategoryAssignees(trip.id, 'Food', [owner.id]);
|
||||
|
||||
const assignees = getCategoryAssignees(trip.id) as any;
|
||||
expect(assignees['Food']).toHaveLength(1);
|
||||
expect(assignees['Food'][0].user_id).toBe(owner.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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 } from '../../helpers/factories';
|
||||
import { exportICS } from '../../../src/services/tripService';
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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');
|
||||
expect(ics).toContain('SUMMARY:Summer Holiday');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,745 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// ── DB setup (real in-memory SQLite) ─────────────────────────────────────────
|
||||
|
||||
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: () => {},
|
||||
canAccessTrip: () => null,
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
// Mock websocket so notifyPlanUsers doesn't throw
|
||||
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
|
||||
import {
|
||||
getOwnPlan,
|
||||
getActivePlan,
|
||||
getPlanUsers,
|
||||
migrateHolidayCalendars,
|
||||
updatePlan,
|
||||
addHolidayCalendar,
|
||||
updateHolidayCalendar,
|
||||
deleteHolidayCalendar,
|
||||
setUserColor,
|
||||
acceptInvite,
|
||||
declineInvite,
|
||||
cancelInvite,
|
||||
getAvailableUsers,
|
||||
listYears,
|
||||
addYear,
|
||||
deleteYear,
|
||||
getEntries,
|
||||
toggleEntry,
|
||||
toggleCompanyHoliday,
|
||||
getStats,
|
||||
applyHolidayCalendars,
|
||||
} from '../../../src/services/vacayService';
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Stub fetch with empty holiday list by default so updatePlan / applyHolidayCalendars
|
||||
// never makes real network calls.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Insert a vacay_plan_members row directly (no service factory for it). */
|
||||
function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void {
|
||||
testDb.prepare(
|
||||
"INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)"
|
||||
).run(planId, userId, status);
|
||||
}
|
||||
|
||||
/** Fast helper: create a user and immediately materialise their own plan. */
|
||||
function setupUserWithPlan() {
|
||||
const { user } = createUser(testDb);
|
||||
const plan = getOwnPlan(user.id);
|
||||
return { user, plan };
|
||||
}
|
||||
|
||||
// ── getOwnPlan ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getOwnPlan', () => {
|
||||
it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const plan = getOwnPlan(user.id);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.owner_id).toBe(user.id);
|
||||
expect(plan.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const first = getOwnPlan(user.id);
|
||||
const second = getOwnPlan(user.id);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const plan = getOwnPlan(user.id);
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
|
||||
.get(plan.id, yr);
|
||||
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const plan = getOwnPlan(user.id);
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||||
.get(user.id, plan.id, yr) as { vacation_days: number } | undefined;
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.vacation_days).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getActivePlan ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getActivePlan', () => {
|
||||
it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const active = getActivePlan(user.id);
|
||||
|
||||
expect(active.id).toBe(plan.id);
|
||||
expect(active.owner_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-006: returns the shared plan when user has an accepted membership in another plan', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: member } = createUser(testDb);
|
||||
// Make sure member also has their own plan materialised first
|
||||
getOwnPlan(member.id);
|
||||
|
||||
insertMember(ownerPlan.id, member.id, 'accepted');
|
||||
|
||||
const active = getActivePlan(member.id);
|
||||
expect(active.id).toBe(ownerPlan.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-007: pending membership does NOT override own plan as active', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: member } = createUser(testDb);
|
||||
getOwnPlan(member.id);
|
||||
|
||||
insertMember(ownerPlan.id, member.id, 'pending');
|
||||
|
||||
const active = getActivePlan(member.id);
|
||||
// Should still point to member's own plan
|
||||
expect(active.owner_id).toBe(member.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPlanUsers ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getPlanUsers', () => {
|
||||
it('VACAY-SVC-008: returns [owner] for a solo plan', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const users = getPlanUsers(plan.id);
|
||||
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', () => {
|
||||
const { user: owner, plan } = setupUserWithPlan();
|
||||
const { user: member } = createUser(testDb);
|
||||
insertMember(plan.id, member.id, 'accepted');
|
||||
|
||||
const users = getPlanUsers(plan.id);
|
||||
|
||||
expect(users).toHaveLength(2);
|
||||
expect(users.map(u => u.id)).toContain(owner.id);
|
||||
expect(users.map(u => u.id)).toContain(member.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-010: pending membership members are NOT included in plan users', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const { user: pendingUser } = createUser(testDb);
|
||||
insertMember(plan.id, pendingUser.id, 'pending');
|
||||
|
||||
const users = getPlanUsers(plan.id);
|
||||
expect(users.map(u => u.id)).not.toContain(pendingUser.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => {
|
||||
const users = getPlanUsers(99999);
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── migrateHolidayCalendars ───────────────────────────────────────────────────
|
||||
|
||||
describe('migrateHolidayCalendars', () => {
|
||||
it('VACAY-SVC-012: does nothing when holidays_enabled is falsy', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' };
|
||||
|
||||
await migrateHolidayCalendars(plan.id, planRow);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||||
.all(plan.id);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-013: inserts a calendar row when holidays_enabled=1 and holidays_region is set', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' };
|
||||
|
||||
await migrateHolidayCalendars(plan.id, planRow);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||||
.all(plan.id) as { region: string }[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].region).toBe('DE');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-014: does nothing if a calendar row already exists (no duplicate)', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' };
|
||||
|
||||
await migrateHolidayCalendars(plan.id, planRow);
|
||||
// Call a second time — should NOT insert another row
|
||||
await migrateHolidayCalendars(plan.id, planRow);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
|
||||
.all(plan.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePlan ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updatePlan', () => {
|
||||
it('VACAY-SVC-015: updates block_weekends flag', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
await updatePlan(plan.id, { block_weekends: true }, undefined);
|
||||
|
||||
const updated = testDb
|
||||
.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?')
|
||||
.get(plan.id) as { block_weekends: number };
|
||||
expect(updated.block_weekends).toBe(1);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-016: updates holidays_enabled flag', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
await updatePlan(plan.id, { holidays_enabled: true }, undefined);
|
||||
|
||||
const updated = testDb
|
||||
.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?')
|
||||
.get(plan.id) as { holidays_enabled: number };
|
||||
expect(updated.holidays_enabled).toBe(1);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const result = await updatePlan(plan.id, { block_weekends: false }, undefined);
|
||||
|
||||
expect(result.plan.block_weekends).toBe(false);
|
||||
expect(typeof result.plan.holidays_enabled).toBe('boolean');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-018: resets carried_over to 0 for all user_years when carry_over_enabled is set to false', async () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
// Manually set a non-zero carried_over value
|
||||
testDb
|
||||
.prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||||
.run(user.id, plan.id, yr);
|
||||
|
||||
await updatePlan(plan.id, { carry_over_enabled: false }, undefined);
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||||
.get(user.id, plan.id, yr) as { carried_over: number };
|
||||
expect(row.carried_over).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── addHolidayCalendar ────────────────────────────────────────────────────────
|
||||
|
||||
describe('addHolidayCalendar', () => {
|
||||
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const cal = addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined);
|
||||
|
||||
expect(cal).toBeDefined();
|
||||
expect(cal.id).toBeGreaterThan(0);
|
||||
expect(cal.region).toBe('GB');
|
||||
expect(cal.label).toBe('UK Holidays');
|
||||
expect(cal.color).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const cal = addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined);
|
||||
|
||||
expect(cal.color).toBe('#fecaca');
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateHolidayCalendar ─────────────────────────────────────────────────────
|
||||
|
||||
describe('updateHolidayCalendar', () => {
|
||||
it('VACAY-SVC-021: changes label and color on an existing calendar', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const cal = addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined);
|
||||
|
||||
const updated = updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined);
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.label).toBe('Deutschland');
|
||||
expect(updated!.color).toBe('#112233');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const result = updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteHolidayCalendar ─────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteHolidayCalendar', () => {
|
||||
it('VACAY-SVC-023: removes the calendar row and returns true on success', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const cal = addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined);
|
||||
|
||||
const result = deleteHolidayCalendar(cal.id, plan.id, undefined);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-024: returns false when the calendar does not exist', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const result = deleteHolidayCalendar(99999, plan.id, undefined);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setUserColor ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('setUserColor', () => {
|
||||
it('VACAY-SVC-025: inserts a color for a user in a plan', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
|
||||
setUserColor(user.id, plan.id, '#123456', undefined);
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
|
||||
.get(user.id, plan.id) as { color: string } | undefined;
|
||||
expect(row?.color).toBe('#123456');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-026: updates the color when called a second time (upsert)', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
setUserColor(user.id, plan.id, '#aaaaaa', undefined);
|
||||
|
||||
setUserColor(user.id, plan.id, '#bbbbbb', undefined);
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
|
||||
.get(user.id, plan.id) as { color: string };
|
||||
expect(row.color).toBe('#bbbbbb');
|
||||
});
|
||||
});
|
||||
|
||||
// ── listYears / addYear / deleteYear ──────────────────────────────────────────
|
||||
|
||||
describe('listYears', () => {
|
||||
it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
const years = listYears(plan.id);
|
||||
|
||||
expect(years).toContain(yr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addYear', () => {
|
||||
it('VACAY-SVC-028: inserts a new year and creates a user_year record', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const newYear = new Date().getFullYear() + 2;
|
||||
|
||||
addYear(plan.id, newYear, undefined);
|
||||
|
||||
const years = listYears(plan.id);
|
||||
expect(years).toContain(newYear);
|
||||
|
||||
const userYear = testDb
|
||||
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||||
.get(user.id, plan.id, newYear) as { vacation_days: number } | undefined;
|
||||
expect(userYear).toBeDefined();
|
||||
expect(userYear!.vacation_days).toBe(30);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-029: carries over remaining days to the new year when carry_over_enabled is true', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const nextYear = currentYear + 1;
|
||||
|
||||
// Enable carry-over and seed some entries for the current year
|
||||
testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id);
|
||||
// Ensure current year row exists with 10 vacation days
|
||||
testDb.prepare(`
|
||||
INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over)
|
||||
VALUES (?, ?, ?, 10, 0)
|
||||
`).run(user.id, plan.id, currentYear);
|
||||
// Add 3 entries (used days) in the current year
|
||||
for (let day = 1; day <= 3; day++) {
|
||||
const dateStr = `${currentYear}-06-0${day}`;
|
||||
testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, '');
|
||||
}
|
||||
|
||||
addYear(plan.id, nextYear, undefined);
|
||||
|
||||
const userYear = testDb
|
||||
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
|
||||
.get(user.id, plan.id, nextYear) as { carried_over: number } | undefined;
|
||||
// 10 vacation days - 3 used = 7 carried over
|
||||
expect(userYear?.carried_over).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteYear', () => {
|
||||
it('VACAY-SVC-030: removes the year row and its associated entries', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const targetYear = new Date().getFullYear() + 3;
|
||||
|
||||
addYear(plan.id, targetYear, undefined);
|
||||
// Insert an entry for that year
|
||||
testDb
|
||||
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
|
||||
.run(plan.id, user.id, `${targetYear}-07-15`, '');
|
||||
|
||||
deleteYear(plan.id, targetYear, undefined);
|
||||
|
||||
const yearRow = testDb
|
||||
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
|
||||
.get(plan.id, targetYear);
|
||||
expect(yearRow).toBeUndefined();
|
||||
|
||||
const entries = testDb
|
||||
.prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?")
|
||||
.all(plan.id, `${targetYear}-%`);
|
||||
expect(entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getEntries / toggleEntry ──────────────────────────────────────────────────
|
||||
|
||||
describe('getEntries', () => {
|
||||
it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear().toString();
|
||||
|
||||
const result = getEntries(plan.id, yr);
|
||||
|
||||
expect(result.entries).toEqual([]);
|
||||
expect(result.companyHolidays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleEntry', () => {
|
||||
it('VACAY-SVC-032: adds an entry on first call (action: added)', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
|
||||
const result = toggleEntry(user.id, plan.id, '2025-08-01', undefined);
|
||||
|
||||
expect(result.action).toBe('added');
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||||
.get(user.id, plan.id, '2025-08-01');
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
|
||||
toggleEntry(user.id, plan.id, '2025-08-02', undefined);
|
||||
const result = toggleEntry(user.id, plan.id, '2025-08-02', undefined);
|
||||
|
||||
expect(result.action).toBe('removed');
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
|
||||
.get(user.id, plan.id, '2025-08-02');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── toggleCompanyHoliday ──────────────────────────────────────────────────────
|
||||
|
||||
describe('toggleCompanyHoliday', () => {
|
||||
it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
const result = toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined);
|
||||
|
||||
expect(result.action).toBe('added');
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
|
||||
.get(plan.id, '2025-12-25');
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined);
|
||||
const result = toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined);
|
||||
|
||||
expect(result.action).toBe('removed');
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
|
||||
.get(plan.id, '2025-12-26');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
|
||||
// First add a personal entry on that date
|
||||
toggleEntry(user.id, plan.id, '2025-05-01', undefined);
|
||||
|
||||
// Now declare it a company holiday — the personal entry should be wiped
|
||||
toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined);
|
||||
|
||||
const personalEntry = testDb
|
||||
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
|
||||
.get(plan.id, '2025-05-01');
|
||||
expect(personalEntry).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── acceptInvite / declineInvite / cancelInvite ───────────────────────────────
|
||||
|
||||
describe('acceptInvite', () => {
|
||||
it('VACAY-SVC-037: changes membership status to accepted', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: invitee } = createUser(testDb);
|
||||
getOwnPlan(invitee.id); // ensure own plan exists for data migration path
|
||||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||||
|
||||
const result = acceptInvite(invitee.id, ownerPlan.id, undefined);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
const row = testDb
|
||||
.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||||
.get(ownerPlan.id, invitee.id) as { status: string } | undefined;
|
||||
expect(row?.status).toBe('accepted');
|
||||
});
|
||||
|
||||
it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const result = acceptInvite(user.id, 99999, undefined);
|
||||
|
||||
expect(result.status).toBe(404);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: invitee } = createUser(testDb);
|
||||
getOwnPlan(invitee.id);
|
||||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||||
|
||||
acceptInvite(invitee.id, ownerPlan.id, undefined);
|
||||
|
||||
const active = getActivePlan(invitee.id);
|
||||
expect(active.id).toBe(ownerPlan.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declineInvite', () => {
|
||||
it('VACAY-SVC-040: removes the pending invite row', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: invitee } = createUser(testDb);
|
||||
insertMember(ownerPlan.id, invitee.id, 'pending');
|
||||
|
||||
declineInvite(invitee.id, ownerPlan.id, undefined);
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||||
.get(ownerPlan.id, invitee.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelInvite', () => {
|
||||
it('VACAY-SVC-041: removes the pending invite when owner cancels it', () => {
|
||||
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
|
||||
const { user: target } = createUser(testDb);
|
||||
insertMember(ownerPlan.id, target.id, 'pending');
|
||||
|
||||
cancelInvite(ownerPlan.id, target.id);
|
||||
|
||||
const row = testDb
|
||||
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
|
||||
.get(ownerPlan.id, target.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAvailableUsers ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAvailableUsers', () => {
|
||||
it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => {
|
||||
const { user: owner, plan } = setupUserWithPlan();
|
||||
const { user: unrelated } = createUser(testDb);
|
||||
getOwnPlan(unrelated.id);
|
||||
|
||||
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
|
||||
|
||||
expect(available.map(u => u.id)).toContain(unrelated.id);
|
||||
// Owner themselves should NOT appear (excluded by u.id != ?)
|
||||
expect(available.map(u => u.id)).not.toContain(owner.id);
|
||||
});
|
||||
|
||||
it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', () => {
|
||||
const { user: owner, plan } = setupUserWithPlan();
|
||||
const { user: alreadyFused } = createUser(testDb);
|
||||
const { plan: otherPlan } = setupUserWithPlan();
|
||||
insertMember(otherPlan.id, alreadyFused.id, 'accepted');
|
||||
|
||||
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
|
||||
|
||||
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getStats ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getStats', () => {
|
||||
it('VACAY-SVC-044: returns per-user stats with correct fields', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
const stats = getStats(plan.id, yr);
|
||||
|
||||
expect(stats).toHaveLength(1);
|
||||
expect(stats[0]).toMatchObject({
|
||||
user_id: user.id,
|
||||
year: yr,
|
||||
vacation_days: 30,
|
||||
used: 0,
|
||||
remaining: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined);
|
||||
toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined);
|
||||
|
||||
const stats = getStats(plan.id, yr);
|
||||
|
||||
expect(stats[0].used).toBe(2);
|
||||
expect(stats[0].remaining).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
// ── applyHolidayCalendars ─────────────────────────────────────────────────────
|
||||
|
||||
describe('applyHolidayCalendars', () => {
|
||||
it('VACAY-SVC-046: does nothing when holidays_enabled is 0 (fetch is never called)', async () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
// holidays_enabled defaults to 0
|
||||
|
||||
await applyHolidayCalendars(plan.id);
|
||||
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('VACAY-SVC-047: deletes matching vacay_entries for a global holiday date returned by the API', async () => {
|
||||
const { user, plan } = setupUserWithPlan();
|
||||
const yr = new Date().getFullYear();
|
||||
|
||||
// Enable holidays and add a calendar
|
||||
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
|
||||
addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined);
|
||||
|
||||
// Add a vacay entry on the holiday date
|
||||
const holidayDate = `${yr}-01-01`;
|
||||
testDb
|
||||
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
|
||||
.run(plan.id, user.id, holidayDate, '');
|
||||
|
||||
// Override fetch to return one global holiday matching that entry
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [{ date: holidayDate, global: true }],
|
||||
}));
|
||||
|
||||
await applyHolidayCalendars(plan.id);
|
||||
|
||||
const remaining = testDb
|
||||
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
|
||||
.all(plan.id, holidayDate);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// Prevent the module-level setInterval from running during tests
|
||||
vi.useFakeTimers();
|
||||
@@ -8,7 +8,14 @@ vi.stubGlobal('fetch', vi.fn());
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
|
||||
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
|
||||
import {
|
||||
estimateCondition,
|
||||
cacheKey,
|
||||
getWeather,
|
||||
getDetailedWeather,
|
||||
ApiError,
|
||||
type WeatherResult,
|
||||
} from '../../../src/services/weatherService';
|
||||
|
||||
// ── estimateCondition ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,3 +112,585 @@ describe('cacheKey', () => {
|
||||
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build a minimal mock Response for fetch. */
|
||||
function mockResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
/** ISO date string offset by `days` from now (fake-timer "now"). */
|
||||
function dateOffset(days: number): string {
|
||||
const d = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── getWeather ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getWeather', () => {
|
||||
// Use coordinates that are unique per describe block to avoid cross-test cache
|
||||
// pollution. Each nested describe uses a distinct lat so the module-level Map
|
||||
// never returns stale data from a sibling test.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
describe('with date — cache hit', () => {
|
||||
it('returns cached result without calling fetch', async () => {
|
||||
const date = dateOffset(2);
|
||||
const forecastBody = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [20],
|
||||
temperature_2m_min: [10],
|
||||
weathercode: [0],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(forecastBody));
|
||||
|
||||
// First call populates the cache
|
||||
const first = await getWeather('10.00', '20.00', date, 'en');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(fetch).mockReset();
|
||||
|
||||
// Second call with identical arguments should be served from cache
|
||||
const second = await getWeather('10.00', '20.00', date, 'en');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with date — forecast path (diffDays -1 .. +16)', () => {
|
||||
it('returns a forecast WeatherResult for a date 3 days away', async () => {
|
||||
const date = dateOffset(3);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [15],
|
||||
weathercode: [1],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('11.00', '21.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('forecast');
|
||||
expect(result.temp).toBe(20); // (25+15)/2
|
||||
expect(result.temp_max).toBe(25);
|
||||
expect(result.temp_min).toBe(15);
|
||||
expect(result.main).toBe('Clear'); // WMO code 1
|
||||
expect(result.description).toBe('Mainly clear');
|
||||
});
|
||||
|
||||
it('uses German descriptions when lang is "de"', async () => {
|
||||
const date = dateOffset(4);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [10],
|
||||
temperature_2m_min: [5],
|
||||
weathercode: [3],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('11.01', '21.01', date, 'de');
|
||||
|
||||
expect(result.description).toBe('Bewolkt'); // German for code 3
|
||||
});
|
||||
|
||||
it('falls back to "Clouds" for an unknown WMO code', async () => {
|
||||
const date = dateOffset(5);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [10],
|
||||
temperature_2m_min: [5],
|
||||
weathercode: [999], // not in WMO_MAP
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('11.02', '21.02', date, 'en');
|
||||
|
||||
expect(result.main).toBe('Clouds');
|
||||
});
|
||||
|
||||
it('throws ApiError when response.ok is false', async () => {
|
||||
const date = dateOffset(2);
|
||||
const body = { reason: 'rate limited' };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
|
||||
|
||||
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toThrow(ApiError);
|
||||
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toMatchObject({
|
||||
status: 429,
|
||||
message: 'rate limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws ApiError when data.error is true', async () => {
|
||||
const date = dateOffset(2);
|
||||
const body = { error: true, reason: 'invalid coordinates' };
|
||||
// Need a fresh coordinate to avoid the cache from the previous test failure
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
|
||||
|
||||
await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
it('falls through to climate path when date is not found in forecast data', async () => {
|
||||
// The forecast API returns data but NOT for our target date; the code
|
||||
// checks idx === -1 and falls into the diffDays > -1 climate branch.
|
||||
const date = dateOffset(3);
|
||||
const forecastBody = {
|
||||
daily: {
|
||||
time: ['1970-01-01'], // deliberately wrong date
|
||||
temperature_2m_max: [10],
|
||||
temperature_2m_min: [5],
|
||||
weathercode: [0],
|
||||
},
|
||||
};
|
||||
|
||||
// Archive response for the climate fallback
|
||||
const refDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
|
||||
const archiveBody = {
|
||||
daily: {
|
||||
time: ['some-date'],
|
||||
temperature_2m_max: [18],
|
||||
temperature_2m_min: [8],
|
||||
precipitation_sum: [0],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(mockResponse(forecastBody))
|
||||
.mockResolvedValueOnce(mockResponse(archiveBody));
|
||||
|
||||
const result = await getWeather('13.00', '23.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('climate');
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with date — past date (diffDays < -1)', () => {
|
||||
it('returns no_forecast error immediately without fetching', async () => {
|
||||
const date = dateOffset(-5); // 5 days in the past
|
||||
|
||||
const result = await getWeather('14.00', '24.00', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with date — climate / archive path (diffDays > 16)', () => {
|
||||
it('returns a climate WeatherResult for a far-future date', async () => {
|
||||
const date = dateOffset(20);
|
||||
const body = {
|
||||
daily: {
|
||||
time: ['2025-01-01', '2025-01-02'],
|
||||
temperature_2m_max: [22, 24],
|
||||
temperature_2m_min: [12, 14],
|
||||
precipitation_sum: [0, 0.1],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('15.00', '25.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('climate');
|
||||
expect(result.temp).toBe(18); // avg of (22+12)/2=17 and (24+14)/2=19 -> avg 18
|
||||
expect(result.temp_max).toBe(23);
|
||||
expect(result.temp_min).toBe(13);
|
||||
});
|
||||
|
||||
it('throws ApiError when archive API response.ok is false', async () => {
|
||||
const date = dateOffset(20);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
|
||||
|
||||
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toThrow(ApiError);
|
||||
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toMatchObject({ status: 500 });
|
||||
});
|
||||
|
||||
it('returns no_forecast when archive daily data is missing', async () => {
|
||||
const date = dateOffset(20);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
|
||||
|
||||
const result = await getWeather('15.02', '25.02', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('returns no_forecast when archive daily.time is empty', async () => {
|
||||
const date = dateOffset(20);
|
||||
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], precipitation_sum: [] } };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('15.03', '25.03', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('returns no_forecast when all temperature entries are null', async () => {
|
||||
const date = dateOffset(20);
|
||||
const body = {
|
||||
daily: {
|
||||
time: ['2025-01-01'],
|
||||
temperature_2m_max: [null],
|
||||
temperature_2m_min: [null],
|
||||
precipitation_sum: [0],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('15.04', '25.04', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without date — current weather path', () => {
|
||||
it('returns current WeatherResult', async () => {
|
||||
const body = {
|
||||
current: { temperature_2m: 18.7, weathercode: 2 },
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('16.00', '26.00', undefined, 'en');
|
||||
|
||||
expect(result.type).toBe('current');
|
||||
expect(result.temp).toBe(19); // Math.round(18.7)
|
||||
expect(result.main).toBe('Clouds'); // WMO code 2
|
||||
expect(result.description).toBe('Partly cloudy');
|
||||
});
|
||||
|
||||
it('uses German descriptions when lang is "de"', async () => {
|
||||
const body = { current: { temperature_2m: 10, weathercode: 45 } };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getWeather('16.01', '26.01', undefined, 'de');
|
||||
|
||||
expect(result.description).toBe('Nebel');
|
||||
});
|
||||
|
||||
it('returns cached current weather on second identical call', async () => {
|
||||
const body = { current: { temperature_2m: 22, weathercode: 0 } };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const first = await getWeather('16.02', '26.02', undefined, 'en');
|
||||
vi.mocked(fetch).mockReset();
|
||||
const second = await getWeather('16.02', '26.02', undefined, 'en');
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('throws ApiError when current weather API returns error', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
|
||||
|
||||
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toThrow(ApiError);
|
||||
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('throws ApiError when data.error flag is set on current weather response', async () => {
|
||||
const body = { error: true, reason: 'quota exceeded' };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
|
||||
|
||||
await expect(getWeather('16.04', '26.04', undefined, 'en')).rejects.toThrow(ApiError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── getDetailedWeather ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getDetailedWeather', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
describe('cache hit', () => {
|
||||
it('returns cached result without calling fetch a second time', async () => {
|
||||
const date = dateOffset(5);
|
||||
const dailyBody = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [28],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [0],
|
||||
precipitation_sum: [0],
|
||||
precipitation_probability_max: [0],
|
||||
windspeed_10m_max: [10],
|
||||
sunrise: [`${date}T06:00`],
|
||||
sunset: [`${date}T20:00`],
|
||||
},
|
||||
hourly: { time: [], temperature_2m: [] },
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(dailyBody));
|
||||
|
||||
const first = await getDetailedWeather('30.00', '40.00', date, 'en');
|
||||
vi.mocked(fetch).mockReset();
|
||||
const second = await getDetailedWeather('30.00', '40.00', date, 'en');
|
||||
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forecast path (diffDays <= 16)', () => {
|
||||
it('returns a detailed forecast WeatherResult with hourly data', async () => {
|
||||
const date = dateOffset(6);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [30],
|
||||
temperature_2m_min: [20],
|
||||
weathercode: [80],
|
||||
precipitation_sum: [5],
|
||||
precipitation_probability_max: [70],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: [`${date}T05:45`],
|
||||
sunset: [`${date}T21:15`],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${date}T12:00`, `${date}T13:00`],
|
||||
temperature_2m: [28, 29],
|
||||
precipitation_probability: [60, 65],
|
||||
precipitation: [1.2, 0.8],
|
||||
weathercode: [80, 81],
|
||||
windspeed_10m: [12, 14],
|
||||
relativehumidity_2m: [70, 68],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('31.00', '41.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('forecast');
|
||||
expect(result.temp).toBe(25); // (30+20)/2
|
||||
expect(result.temp_max).toBe(30);
|
||||
expect(result.temp_min).toBe(20);
|
||||
expect(result.main).toBe('Rain'); // WMO code 80
|
||||
expect(result.precipitation_sum).toBe(5);
|
||||
expect(result.precipitation_probability_max).toBe(70);
|
||||
expect(result.wind_max).toBe(15);
|
||||
expect(result.sunrise).toBe('05:45');
|
||||
expect(result.sunset).toBe('21:15');
|
||||
expect(result.hourly).toHaveLength(2);
|
||||
expect(result.hourly![0].temp).toBe(28);
|
||||
expect(result.hourly![0].precipitation_probability).toBe(60);
|
||||
expect(result.hourly![1].main).toBe('Rain'); // WMO code 81
|
||||
});
|
||||
|
||||
it('returns no_forecast when daily data is missing', async () => {
|
||||
const date = dateOffset(7);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
|
||||
|
||||
const result = await getDetailedWeather('31.01', '41.01', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('returns no_forecast when daily.time is empty', async () => {
|
||||
const date = dateOffset(7);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [],
|
||||
temperature_2m_max: [],
|
||||
temperature_2m_min: [],
|
||||
weathercode: [],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('31.02', '41.02', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('throws ApiError when forecast API returns !ok', async () => {
|
||||
const date = dateOffset(8);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
|
||||
|
||||
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toThrow(ApiError);
|
||||
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('throws ApiError when data.error flag is set', async () => {
|
||||
const date = dateOffset(9);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'bad coords' }));
|
||||
|
||||
await expect(getDetailedWeather('31.04', '41.04', date, 'en')).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
it('handles missing hourly block gracefully', async () => {
|
||||
const date = dateOffset(10);
|
||||
const body = {
|
||||
daily: {
|
||||
time: [date],
|
||||
temperature_2m_max: [20],
|
||||
temperature_2m_min: [10],
|
||||
weathercode: [0],
|
||||
precipitation_sum: [0],
|
||||
precipitation_probability_max: [0],
|
||||
windspeed_10m_max: [5],
|
||||
sunrise: [`${date}T06:00`],
|
||||
sunset: [`${date}T20:00`],
|
||||
},
|
||||
// no hourly field
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('31.05', '41.05', date, 'en');
|
||||
|
||||
expect(result.type).toBe('forecast');
|
||||
expect(result.hourly).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('climate / archive path (diffDays > 16)', () => {
|
||||
it('returns a detailed climate WeatherResult with hourly data', async () => {
|
||||
const date = dateOffset(20);
|
||||
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
|
||||
const refYear = refDate.getFullYear() - 1;
|
||||
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const body = {
|
||||
daily: {
|
||||
time: [refDateStr],
|
||||
temperature_2m_max: [26],
|
||||
temperature_2m_min: [16],
|
||||
weathercode: [63],
|
||||
precipitation_sum: [8],
|
||||
windspeed_10m_max: [20],
|
||||
sunrise: [`${refDateStr}T06:30`],
|
||||
sunset: [`${refDateStr}T20:30`],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${refDateStr}T10:00`, `${refDateStr}T11:00`],
|
||||
temperature_2m: [22, 24],
|
||||
precipitation: [2, 1],
|
||||
weathercode: [63, 61],
|
||||
windspeed_10m: [18, 16],
|
||||
relativehumidity_2m: [80, 75],
|
||||
},
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('32.00', '42.00', date, 'en');
|
||||
|
||||
expect(result.type).toBe('climate');
|
||||
expect(result.temp).toBe(21); // (26+16)/2
|
||||
expect(result.temp_max).toBe(26);
|
||||
expect(result.temp_min).toBe(16);
|
||||
expect(result.main).toBe('Rain'); // WMO code 63
|
||||
expect(result.description).toBe('Rain'); // WMO_DESCRIPTION_EN[63]
|
||||
expect(result.precipitation_sum).toBe(8);
|
||||
expect(result.wind_max).toBe(20);
|
||||
expect(result.sunrise).toBe('06:30');
|
||||
expect(result.sunset).toBe('20:30');
|
||||
expect(result.hourly).toHaveLength(2);
|
||||
expect(result.hourly![0].temp).toBe(22);
|
||||
expect(result.hourly![0].precipitation).toBe(2);
|
||||
expect(result.hourly![1].main).toBe('Rain'); // WMO code 61
|
||||
});
|
||||
|
||||
it('uses German descriptions when lang is "de"', async () => {
|
||||
const date = dateOffset(20);
|
||||
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
|
||||
const refYear = refDate.getFullYear() - 1;
|
||||
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const body = {
|
||||
daily: {
|
||||
time: [refDateStr],
|
||||
temperature_2m_max: [20],
|
||||
temperature_2m_min: [10],
|
||||
weathercode: [0],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [5],
|
||||
},
|
||||
hourly: { time: [], temperature_2m: [] },
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('32.01', '42.01', date, 'de');
|
||||
|
||||
expect(result.description).toBe('Klar'); // German WMO_DESCRIPTION_DE[0]
|
||||
});
|
||||
|
||||
it('returns no_forecast when archive daily data is missing', async () => {
|
||||
const date = dateOffset(20);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
|
||||
|
||||
const result = await getDetailedWeather('32.02', '42.02', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('returns no_forecast when archive daily.time is empty', async () => {
|
||||
const date = dateOffset(20);
|
||||
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('32.03', '42.03', date, 'en');
|
||||
|
||||
expect(result.error).toBe('no_forecast');
|
||||
});
|
||||
|
||||
it('throws ApiError when archive API returns !ok', async () => {
|
||||
const date = dateOffset(20);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
|
||||
|
||||
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toThrow(ApiError);
|
||||
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('throws ApiError when archive data.error flag is set', async () => {
|
||||
const date = dateOffset(20);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'quota exceeded' }));
|
||||
|
||||
await expect(getDetailedWeather('32.05', '42.05', date, 'en')).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
it('falls back to estimateCondition when archive weathercode is undefined', async () => {
|
||||
// When daily.weathercode[0] is undefined, the code falls back to
|
||||
// estimateCondition(avgTemp, precipitation_sum)
|
||||
const date = dateOffset(20);
|
||||
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
|
||||
const refYear = refDate.getFullYear() - 1;
|
||||
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const body = {
|
||||
daily: {
|
||||
time: [refDateStr],
|
||||
temperature_2m_max: [20],
|
||||
temperature_2m_min: [10],
|
||||
// weathercode intentionally omitted — will be undefined
|
||||
precipitation_sum: [10], // > 5 mm and temp > 0 -> 'Rain'
|
||||
windspeed_10m_max: [5],
|
||||
},
|
||||
hourly: { time: [], temperature_2m: [] },
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
|
||||
|
||||
const result = await getDetailedWeather('32.06', '42.06', date, 'en');
|
||||
|
||||
// undefined code -> WMO_MAP[undefined] is undefined -> falls back to estimateCondition
|
||||
// avgTemp = (20+10)/2 = 15, precip = 10 > 5 and temp 15 > 0 -> 'Rain'
|
||||
expect(result.main).toBe('Rain');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user