mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools
- list_budget_items lists a trip's expenses with their ids, payers and splits, page by page so no page is cut down on its way to a model; get_budget_per_person answers who paid and owes what. Both in a new budget-items.mcp.ts, checked like their REST twins. - get_wind_forecast reads the hourly wind, gusts and direction of a day. - get_journey_tracks lists a journey's GPX tracks; the line for drawing rides under a key the provider never receives. - The Dawarich wishlist scan and the countries the recordings suggest become tools, with their confirming writes, in DawarichAtlasMcp in the Atlas group; the shared time window check refuses alike on REST and MCP, and the model never sees when the user was last seen in a city. - The journal's map, tracks and recorded days load with the journey group instead of riding in the core of every step. - The prompt points at list_budget_items for expense ids (the trip summary never carried them) and names the shares and the wind tools; each new read has its own live step.
This commit is contained in:
@@ -108,8 +108,26 @@ function projectTripSummary(summary: Json): Json {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `suggest_atlas_countries` as the model gets it: each country with its
|
||||
* cities and the minutes spent in them, without when the user was last seen
|
||||
* in each city. The model offers countries to mark; a time per city would
|
||||
* hand the provider a dated trail of the user's whereabouts for nothing it
|
||||
* plans with.
|
||||
*/
|
||||
function projectAtlasSuggestions(value: Json): Json {
|
||||
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
|
||||
return {
|
||||
...value,
|
||||
countries: value.countries.map((country) =>
|
||||
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
|
||||
get_trip_summary: projectTripSummary,
|
||||
suggest_atlas_countries: projectAtlasSuggestions,
|
||||
};
|
||||
|
||||
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
|
||||
|
||||
@@ -143,14 +143,28 @@ export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; con
|
||||
],
|
||||
},
|
||||
journey: {
|
||||
about: 'Journeys (the travel journal): entries, photos, the journal linked to this trip, location history.',
|
||||
controllers: ['JourneyMcp', 'TripJournalLinkMcp', 'MemoriesMcp', 'DawarichMcp'],
|
||||
about:
|
||||
'Journeys (the travel journal): entries, photos, the journal linked to this trip, its map and GPX tracks, ' +
|
||||
'location history and the recorded days.',
|
||||
controllers: [
|
||||
'JourneyMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyRecordingsMcp',
|
||||
'JourneyDayImportMcp',
|
||||
'TripJournalLinkMcp',
|
||||
'MemoriesMcp',
|
||||
'DawarichMcp',
|
||||
],
|
||||
},
|
||||
vacay: {
|
||||
about: 'Vacation planner: vacation days per year, public and school holidays, shared calendars.',
|
||||
controllers: ['VacayMcp', 'SchoolHolidaysMcp'],
|
||||
},
|
||||
atlas: { about: 'Atlas: visited countries and regions, the bucket list.', controllers: ['AtlasMcp'] },
|
||||
atlas: {
|
||||
about: 'Atlas: visited countries and regions, the bucket list, and both checked against the Dawarich recordings.',
|
||||
controllers: ['AtlasMcp', 'DawarichAtlasMcp'],
|
||||
},
|
||||
collections: {
|
||||
about: 'Collections: saved places across trips, their labels and ratings.',
|
||||
controllers: ['CollectionsMcp'],
|
||||
|
||||
@@ -29,7 +29,8 @@ How you work:
|
||||
- Propose a deletion only when the user asks for it; it cannot be undone, and the card says so.
|
||||
- You cannot share, invite people, publish links or calendar feeds, change who can see a trip, leave or delete the trip. If asked, say so in one sentence and where in TREK the user can do it.
|
||||
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as <trip_notes> in every later conversation. Never say you cannot remember things.
|
||||
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
|
||||
- Expenses and their ids come from list_budget_items. Change an existing one with update_budget_item; never add a second entry to correct one. For what each person's share comes to, use get_budget_per_person; for who pays whom, get_settlement_summary.
|
||||
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
|
||||
/** The standing instructions, telling the model which language to answer in. */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { BUDGET_ITEMS_PAGE_DEFAULT, budgetItemsPageSchema, idSchema, type BudgetItemsPage } from '@trek/shared';
|
||||
|
||||
/** The whole budget surface rides the budget addon, as in BudgetMcp. */
|
||||
const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
|
||||
|
||||
/**
|
||||
* How long the expenses of one page may run as JSON. The assistant cuts a tool
|
||||
* answer at 40,000 characters, and a page cut there would leave its last rows
|
||||
* unread while next_offset skips past them. So a page ends early, before that
|
||||
* cut, and next_offset starts at the first expense it left out; the totals and
|
||||
* the names next to the page fit in what is left.
|
||||
*/
|
||||
const PAGE_CHARS = 30_000;
|
||||
|
||||
/** How much of an expense's note a page carries: enough to tell two similar expenses apart. */
|
||||
const NOTE_CHARS = 200;
|
||||
|
||||
/** A split or payer list the service always fills; an empty one when it did not. */
|
||||
function rows<T>(list: T[] | undefined): T[] {
|
||||
return list ?? [];
|
||||
}
|
||||
|
||||
function shortNote(note: string | null | undefined): string | null {
|
||||
const line = (note ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!line) return null;
|
||||
return line.length > NOTE_CHARS ? `${line.slice(0, NOTE_CHARS - 1)}…` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* One expense as the tool answers it: the fields a model plans with and a
|
||||
* card shows, without the avatars, receipts and itemised ticket the REST row
|
||||
* carries, and with the start of its note. Amounts stay in the expense's own
|
||||
* currency, which is the trip's when the row names none.
|
||||
*/
|
||||
function slimItem(item: BudgetItem, tripCurrency: string) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
total_price: item.total_price,
|
||||
currency: item.currency || tripCurrency,
|
||||
expense_date: item.expense_date ?? null,
|
||||
note: shortNote(item.note),
|
||||
place_id: item.place_id ?? null,
|
||||
reservation_id: item.reservation_id ?? null,
|
||||
payers: rows(item.payers).map((p) => ({ user_id: p.user_id, amount: p.amount })),
|
||||
members: rows(item.members).map((m) => ({ user_id: m.user_id, amount: m.amount ?? null, paid: m.paid === 1 })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The expenses a page answers: the ones asked for, in order, until their JSON
|
||||
* would pass PAGE_CHARS. Always at least one, so a page is never empty while
|
||||
* expenses are left.
|
||||
*/
|
||||
function fillPage(items: BudgetItem[], tripCurrency: string) {
|
||||
const page: ReturnType<typeof slimItem>[] = [];
|
||||
let chars = 0;
|
||||
for (const item of items) {
|
||||
const slim = slimItem(item, tripCurrency);
|
||||
chars += JSON.stringify(slim).length + 1;
|
||||
if (page.length > 0 && chars > PAGE_CHARS) break;
|
||||
page.push(slim);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The names behind the user ids on a page, once each, so a split reads as people rather than numbers. */
|
||||
function peopleOf(items: BudgetItem[]) {
|
||||
const names = new Map<number, string>();
|
||||
for (const item of items) {
|
||||
for (const person of [...rows(item.members), ...rows(item.payers)]) {
|
||||
if (person.username && !names.has(person.user_id)) names.set(person.user_id, person.username);
|
||||
}
|
||||
}
|
||||
return [...names].sort(([a], [b]) => a - b).map(([user_id, username]) => ({ user_id, username }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The budget's read tools that answer with the rows themselves: the expense
|
||||
* list (the twin of GET /api/trips/:tripId/budget) and the per-person totals
|
||||
* (GET …/budget/summary/per-person). Same service calls and the same trip
|
||||
* access as the routes, which ask for no permission beyond it; BudgetMcp has
|
||||
* the writes and the settlement. A page of the list is a projection small
|
||||
* enough for a model, with the totals of the whole budget next to it.
|
||||
*/
|
||||
@McpController()
|
||||
export class BudgetItemsMcp {
|
||||
constructor(
|
||||
private readonly budget: BudgetService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_budget_items',
|
||||
description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers) and who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share). people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`,
|
||||
inputSchema: { tripId: idSchema, ...budgetItemsPageSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async listBudgetItems({ tripId, offset, limit }: { tripId: number } & BudgetItemsPage, ctx: McpContext) {
|
||||
const trip = await this.budget.verifyTripAccess(tripId, ctx.userId);
|
||||
if (!trip) return noAccess();
|
||||
const currency = (trip.currency || 'EUR').toUpperCase();
|
||||
const items = await this.budget.listBudgetItems(tripId);
|
||||
// The figures get_trip_summary shows: each row at the rate it was booked at (#2525).
|
||||
const totals = await this.budget.tripTotals(
|
||||
tripId,
|
||||
currency,
|
||||
await this.budget.ratesForTripTotals(tripId, currency),
|
||||
);
|
||||
const start = offset ?? 0;
|
||||
const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency);
|
||||
const end = start + page.length;
|
||||
return ok({
|
||||
currency,
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
unconverted_item_ids: totals.unconverted,
|
||||
item_count: items.length,
|
||||
offset: start,
|
||||
next_offset: end < items.length ? end : null,
|
||||
items: page,
|
||||
people: peopleOf(items.slice(start, end)),
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_budget_per_person',
|
||||
description:
|
||||
"What each person on the trip's expenses owes in all and how much of that they paid, in the trip currency: per user total_assigned (their shares of the expenses they are part of), total_paid (the shares marked paid) and items_count. Each share is converted at the rate its expense was booked at; a foreign expense no exchange rate converts counts for nobody. For who has to pay whom to settle up, use get_settlement_summary.",
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async getBudgetPerPerson({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
return ok({ summary: await this.budget.perPersonSummary(tripId) });
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { BudgetItemsMcp } from './budget-items.mcp';
|
||||
import { BudgetController } from './budget.controller';
|
||||
import { BudgetMcp } from './budget.mcp';
|
||||
import { BudgetService } from './budget.service';
|
||||
@@ -25,7 +26,8 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources,
|
||||
* BudgetItemsMcp the reads that answer with the expense rows.
|
||||
* AuthModule is deliberately absent (BudgetMcp's demo guard reads
|
||||
* RuntimeEnvService + the users table, not AuthService) — that absence is
|
||||
* what lets AuthModule import BudgetModule for UserCleanupService.
|
||||
@@ -57,7 +59,7 @@ import { Module } from '@nestjs/common';
|
||||
]),
|
||||
],
|
||||
controllers: [BudgetController],
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc],
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp, BudgetItemsMcp, ExchangeRatesRpc, CostsRpc],
|
||||
// For in-container consumers (CostsRpc, TripsService,
|
||||
// ReservationsService, BookingImportService).
|
||||
exports: [BudgetService, ExchangeRatesService],
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
type McpContext,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
errorResult,
|
||||
ok,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { parseTimeWindow } from './dawarich.helpers';
|
||||
import { dawarichToolResult } from './dawarich.mcp';
|
||||
import {
|
||||
DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
DAWARICH_BUCKET_SCAN_LIMIT,
|
||||
DAWARICH_WINDOW_MAX_DAYS,
|
||||
dawarichAtlasAcceptSchema,
|
||||
dawarichBucketConfirmSchema,
|
||||
dawarichTimeWindowSchema,
|
||||
idSchema,
|
||||
type DawarichAtlasAccept,
|
||||
type DawarichBucketConfirm,
|
||||
type DawarichTimeWindow,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */
|
||||
const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH);
|
||||
|
||||
/**
|
||||
* The bucket list and the Atlas checked against the Dawarich recordings: the
|
||||
* twins of `/api/integrations/dawarich/bucket-list/*` and `/atlas/*`, on the
|
||||
* same service calls.
|
||||
*
|
||||
* Their own class rather than a part of DawarichMcp, because what they read
|
||||
* and write is the Atlas: the assistant loads them with the atlas tools, where
|
||||
* a question about the bucket list or the visited countries goes, while the
|
||||
* stays and the recorded route stay with the journal.
|
||||
*
|
||||
* Reading the recordings to offer wishes and countries is `atlas:read`, as the
|
||||
* answer is about the Atlas; ticking off and marking write it, `atlas:write`.
|
||||
* Like the routes the tools work on the caller's own data and take no trip,
|
||||
* and a refusal reads as the route's error sentence.
|
||||
*/
|
||||
@McpController()
|
||||
export class DawarichAtlasMcp {
|
||||
constructor(
|
||||
private readonly suggestions: DawarichSuggestionsService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'scan_bucket_list_visits',
|
||||
description: `Ask the caller's Dawarich recordings, wish by wish, whether they ever reached the places on their bucket list. A wish counts as reached by a stay of at least ${DAWARICH_BUCKET_MATCH_MIN_MINUTES} minutes within ${DAWARICH_BUCKET_MATCH_RADIUS_M} m; each entry carries that stay (start, minutes, distance, recorded points) or null, and whether the wish was ticked off already. At most ${DAWARICH_BUCKET_SCAN_LIMIT} wishes per scan (truncated says more are left); wishes without coordinates are only counted. An instance that cannot be reached ends the scan with an error rather than a list of misses. Nothing is ticked off here: confirm_bucket_list_visits does that once the user agrees.`,
|
||||
inputSchema: {},
|
||||
// Asks the caller's Dawarich instance once per wish and writes nothing.
|
||||
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async scanBucketList(_args: unknown, ctx: McpContext) {
|
||||
return dawarichToolResult(() => this.suggestions.scanBucketList(ctx.userId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'confirm_bucket_list_visits',
|
||||
description:
|
||||
"Tick off the caller's bucket-list wishes the user confirmed after scan_bucket_list_visits (1 to 200 ids). Each is dated with visitedAt (ISO 8601) when given, otherwise with the start of the stay it was matched to, otherwise today. A wish ticked off already keeps its date. Answers how many were ticked off now (updated).",
|
||||
inputSchema: dawarichBucketConfirmSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async confirmBucketVisits({ itemIds, visitedAt }: DawarichBucketConfirm, ctx: McpContext) {
|
||||
return dawarichToolResult(async () => ({
|
||||
updated: await this.suggestions.confirmBucketVisits(ctx.userId, itemIds, visitedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'clear_bucket_list_visit',
|
||||
description:
|
||||
"Take the visited mark off one of the caller's bucket-list wishes, with its date and where it came from, so the wish reads as not reached yet. The undo of confirm_bucket_list_visits and mark_bucket_list_item_visited_from_dawarich.",
|
||||
inputSchema: { itemId: idSchema.describe('The bucket-list entry') },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async clearBucketVisit({ itemId }: { itemId: number }, ctx: McpContext) {
|
||||
// Someone else's wish answers like a missing one, as the route's 404 does.
|
||||
if (!(await this.suggestions.clearBucketVisit(ctx.userId, itemId))) {
|
||||
return errorResult('Bucket-list entry not found');
|
||||
}
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'suggest_atlas_countries',
|
||||
description: `List the countries the caller's Dawarich recordings put them in between from and to (at most ${DAWARICH_WINDOW_MAX_DAYS} days), each with its cities, the minutes spent there and whether the Atlas counts the country as visited already. Country names TREK could not match to a code are listed under unresolved. Nothing is marked here: accept_atlas_countries does that for the codes the user confirms.`,
|
||||
inputSchema: dawarichTimeWindowSchema.shape,
|
||||
// Reads the caller's Dawarich instance live and writes nothing.
|
||||
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async suggestAtlasCountries({ from, to }: DawarichTimeWindow, ctx: McpContext) {
|
||||
// The route's window check, refused before the instance is asked.
|
||||
const window = parseTimeWindow(from, to);
|
||||
return dawarichToolResult(() => this.suggestions.atlasSuggestions(ctx.userId, window.from, window.to));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'accept_atlas_countries',
|
||||
description:
|
||||
"Mark countries the user confirmed from suggest_atlas_countries as visited in the caller's Atlas, by ISO 3166-1 alpha-2 code (1 to 300 at once). A country taken off the Atlas before comes back; one marked already keeps where it came from. Answers how many were added now (marked).",
|
||||
inputSchema: dawarichAtlasAcceptSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async acceptAtlasCountries({ countryCodes }: DawarichAtlasAccept, ctx: McpContext) {
|
||||
return dawarichToolResult(async () => ({
|
||||
marked: await this.suggestions.acceptAtlasCountries(ctx.userId, countryCodes),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { JourneyDomainService } from '../journey/journey-domain.service';
|
||||
import { JourneyEntryStaysService } from '../journey/journey-entry-stays.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { DawarichClient, type DawarichCreds } from './dawarich.client';
|
||||
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
|
||||
import { minutesBetween, toNumber } from './dawarich.helpers';
|
||||
import { DawarichService } from './dawarich.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -37,8 +37,21 @@ import {
|
||||
type DawarichBucketScan,
|
||||
type DawarichSuggestion,
|
||||
type DawarichSuggestionList,
|
||||
type DawarichErrorCode,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The failures that are the instance's rather than one request's: it cannot
|
||||
* be reached (a timeout included), it turns the key away, or it asks TREK to
|
||||
* slow down. A bucket-list scan stops at the first of them.
|
||||
*/
|
||||
const INSTANCE_FAILURES: ReadonlySet<DawarichErrorCode> = new Set<DawarichErrorCode>([
|
||||
'unreachable',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'rate_limited',
|
||||
]);
|
||||
|
||||
/**
|
||||
* What a user does with the stays a sync brought in.
|
||||
*
|
||||
@@ -408,6 +421,11 @@ export class DawarichSuggestionsService {
|
||||
* One upstream request per wish, so it is capped and explicitly triggered
|
||||
* rather than run on a timer. The cap is reported, never silent: a user with
|
||||
* eighty wishes must not be told their other thirty had no match.
|
||||
*
|
||||
* For the same reason an instance that cannot answer at all ends the scan
|
||||
* with its error instead of reporting every wish as unmatched: one that does
|
||||
* not answer would otherwise hold the request for a timeout per wish, and
|
||||
* one that refuses the key would read as "you never got anywhere".
|
||||
*/
|
||||
async scanBucketList(userId: number): Promise<DawarichBucketScan> {
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
@@ -447,7 +465,10 @@ export class DawarichSuggestionsService {
|
||||
let stays;
|
||||
try {
|
||||
stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// The instance itself is down or turns the key away: every later wish
|
||||
// would fail the same way, so the scan ends here with that error.
|
||||
if (err instanceof DawarichError && INSTANCE_FAILURES.has(err.code)) throw err;
|
||||
// One wish that could not be checked is not a failed scan; it reports as
|
||||
// "no match found" and the user can run it again.
|
||||
return null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DawarichSettingsDto,
|
||||
DawarichSuggestionStateDto,
|
||||
} from './dawarich.dto';
|
||||
import { parseTimeWindow } from './dawarich.helpers';
|
||||
import { DawarichService } from './dawarich.service';
|
||||
import {
|
||||
Body,
|
||||
@@ -176,7 +177,7 @@ export class DawarichController {
|
||||
|
||||
@Get('atlas/suggestions')
|
||||
async atlasSuggestions(@CurrentUser() user: User, @Query('from') from?: string, @Query('to') to?: string) {
|
||||
const window = parseWindow(from, to);
|
||||
const window = parseTimeWindow(from, to);
|
||||
return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to));
|
||||
}
|
||||
|
||||
@@ -215,7 +216,7 @@ export class DawarichController {
|
||||
@Query('to') to?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const window = parseWindow(from, to);
|
||||
const window = parseTimeWindow(from, to);
|
||||
return this.guardAsync(() =>
|
||||
this.tracks.forWindow(user.id, window.from.toISOString(), window.to.toISOString(), parseOffset(offset)),
|
||||
);
|
||||
@@ -310,25 +311,6 @@ function parseDate(raw: string | undefined): string | undefined {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit instant window. Both ends are required together: a half-open
|
||||
* range would silently become "everything since the epoch" on one side, which
|
||||
* is the request that makes an instance time out.
|
||||
*/
|
||||
function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
|
||||
const start = from ? new Date(from) : null;
|
||||
const end = to ? new Date(to) : null;
|
||||
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
|
||||
throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400);
|
||||
}
|
||||
if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400);
|
||||
const MAX_DAYS = 400;
|
||||
if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) {
|
||||
throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400);
|
||||
}
|
||||
return { from: start, to: end };
|
||||
}
|
||||
|
||||
/** The originating client's socket id, so a broadcast does not echo back to it. */
|
||||
function socketId(req: Request): string | undefined {
|
||||
const header = req.headers['x-socket-id'];
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import type {
|
||||
DawarichSlimPoint,
|
||||
DawarichTrackFeature,
|
||||
DawarichVisitRaw,
|
||||
DawarichVisitedCountry,
|
||||
} from './dawarich.client';
|
||||
import { RECORDING_GAP_METERS, type DawarichTrackDay, type DawarichTrackSegment } from '@trek/shared';
|
||||
import {
|
||||
DAWARICH_WINDOW_MAX_DAYS,
|
||||
RECORDING_GAP_METERS,
|
||||
type DawarichTrackDay,
|
||||
type DawarichTrackSegment,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@@ -690,3 +696,25 @@ function sumOf(a: unknown, b: unknown): number | undefined {
|
||||
if (left === null && right === null) return undefined;
|
||||
return (left ?? 0) + (right ?? 0);
|
||||
}
|
||||
|
||||
// ── Windows ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An explicit instant window, read the one way for the REST routes and the
|
||||
* MCP tools that take one. Both ends are required together: a half-open range
|
||||
* would silently become "everything since the epoch" on one side, which is the
|
||||
* request that makes an instance time out. A refusal is a 400 that REST and
|
||||
* MCP answer with the same sentence.
|
||||
*/
|
||||
export function parseTimeWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
|
||||
const start = from ? new Date(from) : null;
|
||||
const end = to ? new Date(to) : null;
|
||||
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
|
||||
throw new DomainError(400, 'from and to are required ISO timestamps');
|
||||
}
|
||||
if (end <= start) throw new DomainError(400, 'to must be after from');
|
||||
if (end.getTime() - start.getTime() > DAWARICH_WINDOW_MAX_DAYS * 86_400_000) {
|
||||
throw new DomainError(400, `Window must be ${DAWARICH_WINDOW_MAX_DAYS} days or less`);
|
||||
}
|
||||
return { from: start, to: end };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { DawarichTracksService } from './dawarich-tracks.service';
|
||||
import { DawarichError } from './dawarich.client';
|
||||
import { idSchema } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -48,6 +49,9 @@ const MAX_LIMIT = 200;
|
||||
* The connection routes (settings, test, disconnect) are deliberately absent:
|
||||
* they take an API key for somebody's complete location history, and that is
|
||||
* exactly what no assistant scope should be able to read or replace.
|
||||
*
|
||||
* The bucket-list and Atlas twins of `/bucket-list/*` and `/atlas/*` are
|
||||
* DawarichAtlasMcp, which the assistant loads with the Atlas.
|
||||
*/
|
||||
@McpController()
|
||||
export class DawarichMcp {
|
||||
@@ -251,11 +255,22 @@ export class DawarichMcp {
|
||||
|
||||
/** The MCP echo of the controller's error shaping: a refusal, not an exception. */
|
||||
private async run<T>(action: () => Promise<T>) {
|
||||
try {
|
||||
return ok((await action()) as object);
|
||||
} catch (err) {
|
||||
if (err instanceof AcceptError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
return dawarichToolResult(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Dawarich tool's answer: the action's result, or a refusal in its words
|
||||
* when it fails as the routes refuse. An instance that cannot be read is a
|
||||
* refusal too, with TREK's own sentence for it (the upstream text stays in
|
||||
* the error's detail, as on the route). Anything else propagates. Shared with
|
||||
* DawarichAtlasMcp.
|
||||
*/
|
||||
export async function dawarichToolResult<T>(action: () => Promise<T>) {
|
||||
try {
|
||||
return ok((await action()) as object);
|
||||
} catch (err) {
|
||||
if (err instanceof AcceptError || err instanceof DawarichError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { DawarichAtlasMcp } from './dawarich-atlas.mcp';
|
||||
import { DawarichDayStaysService } from './dawarich-day-stays.service';
|
||||
import { DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { DawarichSyncJob } from './dawarich-sync.job';
|
||||
@@ -90,6 +91,7 @@ import { Module } from '@nestjs/common';
|
||||
DawarichDayStaysService,
|
||||
DawarichSyncJob,
|
||||
DawarichMcp,
|
||||
DawarichAtlasMcp,
|
||||
],
|
||||
exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService, DawarichDayStaysService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { McpController, TOOL_ANNOTATIONS_READONLY, Tool, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { routeDistance, thinRoute } from './journey-stats';
|
||||
import { idSchema, type JourneyTrack } from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/** Points one track keeps at most: enough for its shape on a small map. */
|
||||
const TRACK_DISPLAY_POINTS = 200;
|
||||
|
||||
/**
|
||||
* Points all tracks of an answer keep together, so a journey of many trips
|
||||
* does not grow the answer by a full line per track. Each track keeps its two
|
||||
* ends whatever the share.
|
||||
*/
|
||||
const TRACKS_DISPLAY_POINTS = 1000;
|
||||
|
||||
/** How many points each of `count` tracks keeps: an even share of the total, between 2 and the per-track cap. */
|
||||
function pointsPerTrack(count: number): number {
|
||||
return Math.max(2, Math.min(TRACK_DISPLAY_POINTS, Math.floor(TRACKS_DISPLAY_POINTS / Math.max(1, count))));
|
||||
}
|
||||
|
||||
/** A coordinate to five decimals, about a metre: finer than any drawn line shows. */
|
||||
function coordinate(value: number): number {
|
||||
return Math.round(value * 1e5) / 1e5;
|
||||
}
|
||||
|
||||
/**
|
||||
* One track as the tool answers it: what the model reasons with (which trip
|
||||
* and place it belongs to, its name and kind, how long it is) and, under
|
||||
* `display_geometry`, the line thinned for an MCP client that draws it. No
|
||||
* card in TREK's own assistant draws it: the assistant drops every key that
|
||||
* names a geometry before the answer reaches the provider, so there the
|
||||
* points cost the model nothing.
|
||||
*/
|
||||
function trackSummary(track: JourneyTrack, points: number) {
|
||||
const distance = routeDistance(track.points.map(([lat, lng]) => ({ lat, lng })));
|
||||
return {
|
||||
place_id: track.place_id,
|
||||
trip_id: track.trip_id,
|
||||
name: track.name,
|
||||
// Absent means a trip's GPX, all GET :id/tracks has ever answered.
|
||||
kind: track.kind ?? 'trip_gpx',
|
||||
color: track.color,
|
||||
point_count: track.points.length,
|
||||
distance_km: Math.round(distance / 100) / 10,
|
||||
display_geometry: thinRoute(track.points, points).map(([lat, lng]) => [coordinate(lat), coordinate(lng)]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPX tracks drawn on a journey's map over MCP: the same service and the
|
||||
* same journey access as GET /api/journeys/:id/tracks, read under the Journey
|
||||
* addon like the route. A track's full geometry can run to thousands of
|
||||
* points, so each answers with its size and a thinned line instead, the
|
||||
* lines of all tracks together held to TRACKS_DISPLAY_POINTS.
|
||||
*/
|
||||
@McpController()
|
||||
export class JourneyTracksMcp {
|
||||
constructor(
|
||||
private readonly journey: JourneyDomainService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_journey_tracks',
|
||||
description: `The GPX tracks a journey's map draws, from the trips added to it: per track the place and trip it belongs to, its name, its kind (trip_gpx for a trip's GPX), how many points it has and its length in km as the line runs. display_geometry holds the line thinned for drawing as [lat, lng] points: at most ${TRACK_DISPLAY_POINTS} per track and about ${TRACKS_DISPLAY_POINTS} for all tracks together, always with both ends. A journey whose trips have no tracks answers an empty list.`,
|
||||
inputSchema: { journeyId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getJourneyTracks({ journeyId }: { journeyId: number }, ctx: McpContext) {
|
||||
const tracks = await this.journey.journeyTracks(journeyId, ctx.userId);
|
||||
if (!tracks) return errorResult('Journey not found or access denied.');
|
||||
const points = pointsPerTrack(tracks.length);
|
||||
return ok({ tracks: tracks.map((track) => trackSummary(track, points)) });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { JourneyMapController } from './journey-map.controller';
|
||||
import { JourneyMapMcp } from './journey-map.mcp';
|
||||
import { JourneyPhotoCaptureModule } from './journey-photo-capture.module';
|
||||
import { JourneyPublicController } from './journey-public.controller';
|
||||
import { JourneyTracksMcp } from './journey-tracks.mcp';
|
||||
import { JourneyController } from './journey.controller';
|
||||
import { journeyImageFileFilter, journeyUploadFilename } from './journey.controller';
|
||||
import { JourneyMcp } from './journey.mcp';
|
||||
@@ -53,6 +54,6 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
MikroOrmModule.forFeature([Users, JourneyBooks]),
|
||||
],
|
||||
controllers: [JourneyController, JourneyMapController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp, JourneyTracksMcp],
|
||||
})
|
||||
export class JourneyModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { WeatherService } from './weather.service';
|
||||
import { WindService } from './wind.service';
|
||||
import { WIND_FORECAST_DAYS, windQuerySchema, type WindQuery } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -13,10 +15,15 @@ import { z } from 'zod';
|
||||
* The catch that answers with the upstream message and `isError: true` rather
|
||||
* than throwing is carried over verbatim: the client sees "Weather service not
|
||||
* available." instead of a transport-level failure when the provider is down.
|
||||
* get_wind_forecast, the twin of GET /api/weather/wind, answers its failures
|
||||
* the same way.
|
||||
*/
|
||||
@McpController()
|
||||
export class WeatherMcp {
|
||||
constructor(private readonly weather: WeatherService) {}
|
||||
constructor(
|
||||
private readonly weather: WeatherService,
|
||||
private readonly wind: WindService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_weather',
|
||||
@@ -38,10 +45,7 @@ export class WeatherMcp {
|
||||
const weather = await this.weather.get(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +69,30 @@ export class WeatherMcp {
|
||||
const weather = await this.weather.getDetailed(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_wind_forecast',
|
||||
description: `Get the wind forecast for a location (lat, lng) on one day (date, YYYY-MM-DD): per local hour the mean speed and the gusts in km/h and the direction the wind comes from in degrees, and for the day the strongest mean, the strongest gust and the dominant direction. Only a forecast is answered: a day in the past or more than ${WIND_FORECAST_DAYS} days ahead comes back with its status (past, beyond_horizon) and no figures. A value the source did not give is null, never calm.`,
|
||||
inputSchema: windQuerySchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'weather', mode: 'read' },
|
||||
})
|
||||
async getWindForecast({ lat, lng, date }: WindQuery, _ctx: McpContext) {
|
||||
try {
|
||||
return ok({ wind: await this.wind.forecast({ lat, lng, date }) });
|
||||
} catch (err) {
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider's failure as a refusal the caller can read, never a transport error. */
|
||||
function unavailable(err: unknown) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ import { BudgetSettlements } from '../../src/db/entities/BudgetSettlements.entit
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import type { McpContext } from '../../src/nest-mcp';
|
||||
import { BudgetItemsMcp } from '../../src/nest/budget/budget-items.mcp';
|
||||
import { BudgetModule } from '../../src/nest/budget/budget.module';
|
||||
import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
@@ -29,6 +32,7 @@ import { makeUser } from '../helpers/factories/users';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -861,4 +865,77 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
|
||||
expect(balances.find((b) => b.user_id === 3)?.balance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the read tools over MCP', () => {
|
||||
let mcpTripId: number;
|
||||
const asUser = (userId: number) => ({ userId }) as McpContext;
|
||||
const call = <T>(fn: () => Promise<T>) => withRequestContext(app.get(MikroORM), fn);
|
||||
const text = (result: { content: { text: string }[] }) => JSON.parse(result.content[0]?.text ?? '');
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpTripId = (await makeTrip(orm, 1, { title: 'MCP Trip', currency: 'EUR' })).id;
|
||||
await addTripMember(orm, mcpTripId, 2);
|
||||
await makeUser(orm, {
|
||||
id: 6,
|
||||
username: 'e2e-mcp-outsider',
|
||||
email: 'mcp-outsider@example.test',
|
||||
password_version: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('BUDGET-E2E-001: list_budget_items answers the expenses REST lists, in its order, with the trip totals', async () => {
|
||||
const create = (body: object) =>
|
||||
request(server).post(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(1)).send(body);
|
||||
// One after the other, so the list order is the order they were made in.
|
||||
const ferry = await create({ name: 'Ferry', category: 'transport', total_price: 60, member_ids: [1, 2] });
|
||||
const diner = await create({
|
||||
name: 'Diner',
|
||||
category: 'food',
|
||||
currency: 'USD',
|
||||
exchange_rate: 1.2,
|
||||
payers: [{ user_id: 2, amount: 36 }],
|
||||
});
|
||||
expect([ferry.status, diner.status]).toEqual([201, 201]);
|
||||
const rest = await request(server).get(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(1));
|
||||
expect(rest.status).toBe(200);
|
||||
|
||||
const tool = text(await call(() => app.get(BudgetItemsMcp).listBudgetItems({ tripId: mcpTripId }, asUser(2))));
|
||||
|
||||
type RestItem = { id: number; name: string; total_price: number; currency: string | null; members: unknown[] };
|
||||
expect(tool.items.map((item: { id: number }) => item.id)).toEqual(rest.body.items.map((i: RestItem) => i.id));
|
||||
expect(tool.items.map((item: { name: string; total_price: number }) => [item.name, item.total_price])).toEqual(
|
||||
rest.body.items.map((i: RestItem) => [i.name, i.total_price]),
|
||||
);
|
||||
// 60 EUR and 36 USD at 1.2: 30 EUR.
|
||||
expect(tool).toMatchObject({ currency: 'EUR', total: 90, item_count: 2, next_offset: null });
|
||||
});
|
||||
|
||||
it('BUDGET-E2E-002: get_budget_per_person answers exactly what the per-person route answers', async () => {
|
||||
const rest = await request(server)
|
||||
.get(`/api/trips/${mcpTripId}/budget/summary/per-person`)
|
||||
.set('Cookie', sessionCookie(1));
|
||||
expect(rest.status).toBe(200);
|
||||
|
||||
const tool = text(await call(() => app.get(BudgetItemsMcp).getBudgetPerPerson({ tripId: mcpTripId }, asUser(1))));
|
||||
|
||||
expect(tool).toEqual(rest.body);
|
||||
expect(tool.summary.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('BUDGET-E2E-003: someone off the trip gets the REST 404 and the MCP trip refusal from both tools', async () => {
|
||||
const rest = await request(server).get(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(6));
|
||||
expect(rest.status).toBe(404);
|
||||
|
||||
const mcp = app.get(BudgetItemsMcp);
|
||||
for (const result of [
|
||||
await call(() => mcp.listBudgetItems({ tripId: mcpTripId }, asUser(6))),
|
||||
await call(() => mcp.getBudgetPerPerson({ tripId: mcpTripId }, asUser(6))),
|
||||
]) {
|
||||
expect(result).toEqual({
|
||||
content: [{ type: 'text', text: 'Trip not found or access denied.' }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,21 +15,31 @@
|
||||
* 401, the shape of a connection nobody has configured, the two URL rejections
|
||||
* that need no DNS, the suggestions envelope's `connected: false`, and the
|
||||
* cross-user isolation — someone else's suggestion is invisible and untouchable
|
||||
* through your own session, and answers the same 404 a missing one does.
|
||||
* through your own session, and answers the same 404 a missing one does. The
|
||||
* bucket-list and Atlas tools of DawarichAtlasMcp run next to their routes on
|
||||
* the same container, so the two channels are held to the same answers.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { BucketList } from '../../src/db/entities/BucketList.entity';
|
||||
import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity';
|
||||
import { DawarichVisitSuggestions } from '../../src/db/entities/DawarichVisitSuggestions.entity';
|
||||
import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity';
|
||||
import { trekMcpErrorMapper } from '../../src/mcp/nest-mcp-policy';
|
||||
import type { McpContext, McpTextResult } from '../../src/nest-mcp';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { DawarichClient } from '../../src/nest/integrations/dawarich.client';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { DawarichAtlasMcp } from '../../src/nest/integrations/dawarich-atlas.mcp';
|
||||
import { DawarichClient, DawarichError } from '../../src/nest/integrations/dawarich.client';
|
||||
import { DawarichModule } from '../../src/nest/integrations/dawarich.module';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { makeBucketListItem } from '../helpers/factories/atlas';
|
||||
import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
@@ -377,4 +387,126 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp
|
||||
expect(res.body.state).toBe('dismissed');
|
||||
expect((await findRow(orm, DawarichVisitSuggestions, { id: mine }))?.state).toBe('dismissed');
|
||||
});
|
||||
|
||||
// ── Bucket list and Atlas over MCP: the same services, the same answers ──
|
||||
|
||||
const asUser = (userId: number) => ({ userId, scopes: null, isStaticToken: false }) as McpContext;
|
||||
const viaMcp = <T>(fn: (mcp: DawarichAtlasMcp) => Promise<T>) =>
|
||||
withRequestContext(app.get(MikroORM), () => fn(app.get(DawarichAtlasMcp)));
|
||||
const answerOf = (result: McpTextResult) => JSON.parse(result.content[0]?.text ?? '');
|
||||
|
||||
it('DAWARICH-E2E-040: a wish ticked off over MCP is ticked off for REST, and only its owner can clear it', async () => {
|
||||
const wish = await makeBucketListItem(orm, ownerId, { name: 'Alhambra', lat: 37.176, lng: -3.588 });
|
||||
|
||||
const confirmed = await viaMcp((mcp) =>
|
||||
mcp.confirmBucketVisits({ itemIds: [wish.id], visitedAt: '2024-04-02T10:00:00Z' }, asUser(ownerId)),
|
||||
);
|
||||
expect(answerOf(confirmed)).toEqual({ updated: 1 });
|
||||
const again = await request(server)
|
||||
.post('/api/integrations/dawarich/bucket-list/confirm')
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ itemIds: [wish.id] });
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body).toEqual({ updated: 0 });
|
||||
expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({
|
||||
visited_at: '2024-04-02T10:00:00Z',
|
||||
visited_source: 'dawarich',
|
||||
});
|
||||
|
||||
const foreign = await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(strangerId)));
|
||||
expect(foreign).toEqual({ content: [{ type: 'text', text: 'Bucket-list entry not found' }], isError: true });
|
||||
const foreignRest = await request(server)
|
||||
.delete(`/api/integrations/dawarich/bucket-list/${wish.id}/visit`)
|
||||
.set('Cookie', sessionCookie(strangerId));
|
||||
expect(foreignRest.status).toBe(404);
|
||||
expect(foreignRest.body).toEqual({ error: 'Bucket-list entry not found' });
|
||||
|
||||
expect(answerOf(await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(ownerId))))).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({ visited_at: null, visited_source: null });
|
||||
});
|
||||
|
||||
it('DAWARICH-E2E-041: countries accepted over MCP land in the Atlas once, as REST then finds them', async () => {
|
||||
const marked = await viaMcp((mcp) => mcp.acceptAtlasCountries({ countryCodes: ['PT', 'es'] }, asUser(ownerId)));
|
||||
expect(answerOf(marked)).toEqual({ marked: 2 });
|
||||
|
||||
const again = await request(server)
|
||||
.post('/api/integrations/dawarich/atlas/accept')
|
||||
.set('Cookie', sessionCookie(ownerId))
|
||||
.send({ countryCodes: ['PT', 'ES'] });
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body).toEqual({ marked: 0 });
|
||||
expect(await countRows(orm, VisitedCountries, { user: ownerId, source: 'dawarich' })).toBe(2);
|
||||
});
|
||||
|
||||
it('DAWARICH-E2E-042: without a connection both reads refuse alike, and a backwards window is the route 400 before anything is asked', async () => {
|
||||
const scan = await request(server)
|
||||
.post('/api/integrations/dawarich/bucket-list/scan')
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
expect(scan.status).toBe(400);
|
||||
expect(scan.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' });
|
||||
expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({
|
||||
content: [{ type: 'text', text: 'Dawarich is not connected' }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const window = { from: '2026-01-01T00:00:00Z', to: '2026-03-01T00:00:00Z' };
|
||||
const suggestions = await request(server)
|
||||
.get('/api/integrations/dawarich/atlas/suggestions')
|
||||
.query(window)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
expect(suggestions.status).toBe(400);
|
||||
expect(suggestions.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' });
|
||||
expect(await viaMcp((mcp) => mcp.suggestAtlasCountries(window, asUser(ownerId)))).toEqual({
|
||||
content: [{ type: 'text', text: 'Dawarich is not connected' }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
const backwards = { from: window.to, to: window.from };
|
||||
const refused = await request(server)
|
||||
.get('/api/integrations/dawarich/atlas/suggestions')
|
||||
.query(backwards)
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body).toEqual({ error: 'to must be after from' });
|
||||
const thrown = await viaMcp((mcp) => mcp.suggestAtlasCountries(backwards, asUser(ownerId))).then(
|
||||
() => undefined,
|
||||
(err: unknown) => err,
|
||||
);
|
||||
expect(thrown).toMatchObject({ status: 400, publicMessage: 'to must be after from' });
|
||||
// The tool call answers with the route's sentence: the registry maps the refusal to an error result.
|
||||
expect(trekMcpErrorMapper(thrown)).toEqual({
|
||||
content: [{ type: 'text', text: 'to must be after from' }],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-E2E-043: an instance that does not answer ends a scan after one lookup: the route 502 and the same sentence over MCP', async () => {
|
||||
await insertRow(orm, DawarichConnections, {
|
||||
user: ownerId,
|
||||
url: 'https://dawarich.example',
|
||||
api_key: 'plain-legacy-key',
|
||||
});
|
||||
await Promise.all(
|
||||
['Alhambra', 'Petra'].map((name) => makeBucketListItem(orm, ownerId, { name, lat: 37.176, lng: -3.588 })),
|
||||
);
|
||||
const findVisitsNear = vi.mocked(app.get(DawarichClient).findVisitsNear);
|
||||
findVisitsNear.mockClear();
|
||||
const down = () => new DawarichError('unreachable', 'Could not reach Dawarich', undefined, 'ETIMEDOUT');
|
||||
findVisitsNear.mockRejectedValueOnce(down()).mockRejectedValueOnce(down());
|
||||
|
||||
const scan = await request(server)
|
||||
.post('/api/integrations/dawarich/bucket-list/scan')
|
||||
.set('Cookie', sessionCookie(ownerId));
|
||||
expect(scan.status).toBe(502);
|
||||
expect(scan.body).toEqual({ error: 'Could not reach Dawarich', code: 'unreachable', detail: 'ETIMEDOUT' });
|
||||
expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({
|
||||
content: [{ type: 'text', text: 'Could not reach Dawarich' }],
|
||||
isError: true,
|
||||
});
|
||||
// One lookup per channel, not one per wish.
|
||||
expect(findVisitsNear).toHaveBeenCalledTimes(2);
|
||||
await deleteRows(orm, BucketList, { user: ownerId });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
|
||||
* codes and the unguarded public route.
|
||||
*/
|
||||
import type { McpContext } from '../../src/nest-mcp';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { notFound } from '../../src/nest/common/domain-error';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
@@ -13,6 +14,7 @@ import { JourneyBookService } from '../../src/nest/journey/journey-book.service'
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { JourneyMapService } from '../../src/nest/journey/journey-map.service';
|
||||
import { JourneyShareService } from '../../src/nest/journey/journey-share.service';
|
||||
import { JourneyTracksMcp } from '../../src/nest/journey/journey-tracks.mcp';
|
||||
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
||||
import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
@@ -102,6 +104,7 @@ const { jsvc } = vi.hoisted(() => ({
|
||||
uploadGalleryPhotos: vi.fn(),
|
||||
broadcastJourneyEvent: vi.fn(),
|
||||
journeyIdOfEntry: vi.fn(),
|
||||
journeyTracks: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -616,4 +619,59 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(missing.status).toBe(404);
|
||||
expect(missing.body).toEqual({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// ── GPX tracks: the route and get_journey_tracks read the same service ────
|
||||
|
||||
const TRACK = {
|
||||
place_id: 4,
|
||||
trip_id: 2,
|
||||
name: 'Lagoon loop',
|
||||
color: '#1971c2',
|
||||
points: [
|
||||
[45.43, 12.33],
|
||||
[45.44, 12.34],
|
||||
] as [number, number][],
|
||||
};
|
||||
const asMcp = { userId: 1 } as McpContext;
|
||||
|
||||
it('JOURNEY-E2E-001: the tracks route and get_journey_tracks answer the same tracks, the tool by size with a thinned line', async () => {
|
||||
jsvc.journeyTracks.mockReset();
|
||||
jsvc.journeyTracks.mockResolvedValue([TRACK]);
|
||||
|
||||
const res = await request(server).get('/api/journeys/9/tracks').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ tracks: [TRACK] });
|
||||
|
||||
const tool = await app.get(JourneyTracksMcp).getJourneyTracks({ journeyId: 9 }, asMcp);
|
||||
expect(JSON.parse(tool.content[0]?.text ?? '')).toEqual({
|
||||
tracks: [
|
||||
{
|
||||
place_id: 4,
|
||||
trip_id: 2,
|
||||
name: 'Lagoon loop',
|
||||
kind: 'trip_gpx',
|
||||
color: '#1971c2',
|
||||
point_count: 2,
|
||||
distance_km: 1.4,
|
||||
display_geometry: TRACK.points,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(jsvc.journeyTracks.mock.calls).toEqual([
|
||||
[9, 1],
|
||||
[9, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('JOURNEY-E2E-002: a journey out of reach is a 404 on the route and the journey refusal over MCP', async () => {
|
||||
jsvc.journeyTracks.mockReset();
|
||||
jsvc.journeyTracks.mockResolvedValue(null);
|
||||
|
||||
const res = await request(server).get('/api/journeys/9/tracks').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Journey not found' });
|
||||
|
||||
const tool = await app.get(JourneyTracksMcp).getJourneyTracks({ journeyId: 9 }, asMcp);
|
||||
expect(tool).toEqual({ content: [{ type: 'text', text: 'Journey not found or access denied.' }], isError: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
* real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
|
||||
* The weather service is mocked so no real Open-Meteo calls happen.
|
||||
*/
|
||||
import type { McpContext } from '../../src/nest-mcp';
|
||||
import { DomainError } from '../../src/nest/common/domain-error';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { WeatherMcp } from '../../src/nest/weather/weather.mcp';
|
||||
import { WeatherModule } from '../../src/nest/weather/weather.module';
|
||||
import { WIND_UNAVAILABLE_MESSAGE, WindService } from '../../src/nest/weather/wind.service';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTempDb, seedUser, sessionCookie } from './harness';
|
||||
@@ -49,6 +54,8 @@ describe('Weather e2e (real auth guard + temp SQLite)', () => {
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
// Mirror the production APP_PIPE: the wind route's DTO query is coerced as under buildApp().
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
@@ -103,4 +110,58 @@ describe('Weather e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ type: 'forecast' });
|
||||
});
|
||||
|
||||
// ── Wind: GET /wind and get_wind_forecast ask the one service alike ───────
|
||||
|
||||
const WIND = {
|
||||
status: 'forecast' as const,
|
||||
date: '2026-07-01',
|
||||
lat: 38.7,
|
||||
lng: -9.1,
|
||||
timezone: 'Europe/Lisbon',
|
||||
source: 'Open-Meteo' as const,
|
||||
fetched_at: '2026-06-30T08:00:00.000Z',
|
||||
horizon_days: 16,
|
||||
hours: [{ hour: 14, speed_kmh: 22, gusts_kmh: 38, direction_deg: 320 }],
|
||||
day: { speed_max_kmh: 22, gusts_max_kmh: 38, direction_dominant_deg: 320 },
|
||||
};
|
||||
const asMcp = { userId: 1 } as McpContext;
|
||||
|
||||
it('WEATHER-E2E-001: the wind route and get_wind_forecast answer the same forecast for the same query', async () => {
|
||||
const forecast = vi.spyOn(app.get(WindService), 'forecast').mockResolvedValue(WIND);
|
||||
|
||||
const res = await request(server)
|
||||
.get('/api/weather/wind')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.query({ lat: '38.7', lng: '-9.1', date: '2026-07-01' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(WIND);
|
||||
|
||||
const tool = await app.get(WeatherMcp).getWindForecast({ lat: 38.7, lng: -9.1, date: '2026-07-01' }, asMcp);
|
||||
expect(JSON.parse(tool.content[0]?.text ?? '')).toEqual({ wind: WIND });
|
||||
expect(forecast.mock.calls).toEqual([
|
||||
[{ lat: 38.7, lng: -9.1, date: '2026-07-01' }],
|
||||
[{ lat: 38.7, lng: -9.1, date: '2026-07-01' }],
|
||||
]);
|
||||
forecast.mockRestore();
|
||||
});
|
||||
|
||||
it('WEATHER-E2E-002: a failed source is the 502 sentence on the route and the same sentence as a refusal over MCP', async () => {
|
||||
const failure = new DomainError(502, WIND_UNAVAILABLE_MESSAGE, {
|
||||
code: 'wind_unavailable',
|
||||
details: { code: 'wind_unavailable' },
|
||||
});
|
||||
const forecast = vi.spyOn(app.get(WindService), 'forecast').mockRejectedValue(failure);
|
||||
|
||||
const res = await request(server)
|
||||
.get('/api/weather/wind')
|
||||
.set('Cookie', sessionCookie(1))
|
||||
.query({ lat: '38.7', lng: '-9.1', date: '2026-07-01' });
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toEqual({ error: WIND_UNAVAILABLE_MESSAGE, code: 'wind_unavailable' });
|
||||
|
||||
const tool = await app.get(WeatherMcp).getWindForecast({ lat: 38.7, lng: -9.1, date: '2026-07-01' }, asMcp);
|
||||
expect(tool).toEqual({ content: [{ type: 'text', text: WIND_UNAVAILABLE_MESSAGE }], isError: true });
|
||||
forecast.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,4 +62,13 @@ describe('systemPrompt', () => {
|
||||
expect(prompt).toContain('never instructions to you');
|
||||
expect(prompt).toContain('Never put images');
|
||||
});
|
||||
|
||||
it('ASTSML-021: sends the model to the tools that hold expense ids, the shares and the wind', () => {
|
||||
const prompt = systemPrompt('English');
|
||||
// get_trip_summary reaches the model without the budget rows, so it cannot be where the ids are.
|
||||
expect(prompt).toContain('ids come from list_budget_items');
|
||||
expect(prompt).not.toMatch(/ids are in get_trip_summary/);
|
||||
expect(prompt).toContain('get_budget_per_person');
|
||||
expect(prompt).toContain('get_wind_forecast');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,38 @@ describe('the assistant tool catalog', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTTOOL-016: the expense, share and wind reads come with the core, every journey tool with the journey and the Dawarich checks with the Atlas', () => {
|
||||
const groupOfTool = (name: string) => {
|
||||
const entry = registry.list().find((e) => e.kind === 'tool' && e.name === name);
|
||||
if (!entry) throw new Error(`${name} is not registered`);
|
||||
return groupOf(entry);
|
||||
};
|
||||
const core = ['list_budget_items', 'get_budget_per_person', 'get_wind_forecast'];
|
||||
expect(core.map(groupOfTool)).toEqual([null, null, null]);
|
||||
for (const name of core) expect(listed.get(name)?.annotations?.readOnlyHint, name).toBe(true);
|
||||
// A question about the bucket list or the visited countries loads the Atlas, so the checks against the
|
||||
// recordings come with it.
|
||||
const atlas = [
|
||||
'scan_bucket_list_visits',
|
||||
'suggest_atlas_countries',
|
||||
'confirm_bucket_list_visits',
|
||||
'clear_bucket_list_visit',
|
||||
'accept_atlas_countries',
|
||||
];
|
||||
expect(atlas.map(groupOfTool)).toEqual(atlas.map(() => 'atlas'));
|
||||
// The journal's map, tracks, recorded days and day stays are never core: they wait for the journey group
|
||||
// like the rest of the journal.
|
||||
const journey = [
|
||||
'get_journey_tracks',
|
||||
'get_journey_map_layers',
|
||||
'list_journey_recorded_days',
|
||||
'save_journey_recorded_day',
|
||||
'review_journey_day_stays',
|
||||
'take_over_journey_day_stays',
|
||||
];
|
||||
expect(journey.map(groupOfTool)).toEqual(journey.map(() => 'journey'));
|
||||
});
|
||||
|
||||
it('ASTTOOL-017: the hike tools come with the tours, the proposal runs at once and the writes are cards of a tour', () => {
|
||||
const hikeTools = ['propose_hike', 'create_hike', 'update_tour', 'import_tour_gpx'];
|
||||
const entries = hikeTools.map((name) => registry.list().find((e) => e.kind === 'tool' && e.name === name));
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* BudgetItemsMcp: the expense list and the per-person totals over MCP, on the
|
||||
* same service calls and the same trip access as GET /api/trips/:tripId/budget
|
||||
* and GET …/budget/summary/per-person. The suite takes its subject from the
|
||||
* container over a snapshot test db; only the exchange rates are stubbed, so
|
||||
* nothing reaches the network. The addon gate is the registry's.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
|
||||
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact';
|
||||
import { BudgetItemsMcp } from '../../../src/nest/budget/budget-items.mcp';
|
||||
import { BudgetModule } from '../../../src/nest/budget/budget.module';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import type { BudgetItem } from '../../../src/types';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addBudgetItemMember, addBudgetItemPayer, makeBudgetItem } from '../../helpers/factories/budget';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { makeReservation } from '../../helpers/factories/reservations';
|
||||
import { insertRows } from '../../helpers/factories/rows';
|
||||
import { addTripMember, makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: BudgetItemsMcp;
|
||||
/** Today's quote against the trip currency; null is a server that cannot quote. */
|
||||
const getRates = vi.fn<(base: string) => Promise<Record<string, number> | null>>();
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [BudgetModule],
|
||||
overrides: [{ provide: ExchangeRatesService, useValue: { getRates } }],
|
||||
});
|
||||
mcp = t.get(BudgetItemsMcp);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
getRates.mockReset();
|
||||
getRates.mockResolvedValue(null);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* A euro trip of two: a dinner on a place the two split evenly, paid by the
|
||||
* owner, of which the guest paid their share back; and a museum booking in
|
||||
* dollars at a frozen rate, split unevenly and paid by the guest. A third user
|
||||
* cannot open the trip.
|
||||
*/
|
||||
async function fixture() {
|
||||
const { user: owner } = await makeUser(orm, { username: 'ana' });
|
||||
const { user: guest } = await makeUser(orm, { username: 'ben' });
|
||||
const { user: outsider } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, owner.id, { title: 'Lisbon', currency: 'EUR' });
|
||||
await addTripMember(orm, trip.id, guest.id);
|
||||
const place = await makePlace(orm, trip.id, { name: 'Time Out Market' });
|
||||
const booking = await makeReservation(orm, trip.id, { title: 'Gulbenkian', type: 'activity' });
|
||||
const dinner = await makeBudgetItem(orm, trip.id, {
|
||||
name: 'Dinner',
|
||||
category: 'food',
|
||||
total_price: 90,
|
||||
place: place.id,
|
||||
expense_date: '2026-05-02',
|
||||
note: 'Window table,\n tip included',
|
||||
sort_order: 1,
|
||||
});
|
||||
await addBudgetItemMember(orm, dinner.id, owner.id, { paid: 1 });
|
||||
await addBudgetItemMember(orm, dinner.id, guest.id, { paid: 1 });
|
||||
await addBudgetItemPayer(orm, dinner.id, owner.id, 90);
|
||||
// 1.25 dollars to the euro, frozen when it was booked: 50 USD is 40 EUR.
|
||||
const museum = await makeBudgetItem(orm, trip.id, {
|
||||
name: 'Museum',
|
||||
category: 'activities',
|
||||
total_price: 50,
|
||||
currency: 'USD',
|
||||
exchange_rate: 1.25,
|
||||
reservation: booking.id,
|
||||
sort_order: 2,
|
||||
});
|
||||
await addBudgetItemMember(orm, museum.id, owner.id, { amount: 30 });
|
||||
await addBudgetItemMember(orm, museum.id, guest.id, { amount: 20, paid: 1 });
|
||||
await addBudgetItemPayer(orm, museum.id, guest.id, 50);
|
||||
return { owner, guest, outsider, trip, place, booking, dinner, museum };
|
||||
}
|
||||
|
||||
function toolOptions(method: string) {
|
||||
const entry = getEntry(BudgetItemsMcp as unknown as ClassRef, method);
|
||||
if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`);
|
||||
return entry.options;
|
||||
}
|
||||
|
||||
describe('BudgetItemsMcp surface', () => {
|
||||
it.each([
|
||||
['listBudgetItems', 'list_budget_items'],
|
||||
['getBudgetPerPerson', 'get_budget_per_person'],
|
||||
])(
|
||||
'BGI-MCP-001: %s is the read-only %s, a budget read under the budget addon, broadcasting nothing',
|
||||
async (method, name) => {
|
||||
const options = toolOptions(method);
|
||||
expect(options.name).toBe(name);
|
||||
expect(options.access).toEqual({ group: 'budget', mode: 'read' });
|
||||
expect(options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false);
|
||||
expect(await options.when?.(ctx(1), mcp)).toBe(false);
|
||||
expect(enabled).toHaveBeenCalledWith(ADDON_IDS.BUDGET);
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await options.when?.(ctx(1), mcp)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('list_budget_items', () => {
|
||||
it('BGI-MCP-010: answers each expense slim, with its links, payers and split, and the totals in the trip currency', async () => {
|
||||
const f = await fixture();
|
||||
|
||||
const answer = read(await mcp.listBudgetItems({ tripId: f.trip.id }, ctx(f.guest.id)));
|
||||
|
||||
expect(answer).toEqual({
|
||||
currency: 'EUR',
|
||||
total: 130,
|
||||
by_category: { food: 90, activities: 40 },
|
||||
unconverted_item_ids: [],
|
||||
item_count: 2,
|
||||
offset: 0,
|
||||
next_offset: null,
|
||||
items: [
|
||||
{
|
||||
id: f.dinner.id,
|
||||
name: 'Dinner',
|
||||
category: 'food',
|
||||
total_price: 90,
|
||||
currency: 'EUR',
|
||||
expense_date: '2026-05-02',
|
||||
note: 'Window table, tip included',
|
||||
place_id: f.place.id,
|
||||
reservation_id: null,
|
||||
payers: [{ user_id: f.owner.id, amount: 90 }],
|
||||
members: [
|
||||
{ user_id: f.owner.id, amount: null, paid: true },
|
||||
{ user_id: f.guest.id, amount: null, paid: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: f.museum.id,
|
||||
name: 'Museum',
|
||||
category: 'activities',
|
||||
total_price: 50,
|
||||
currency: 'USD',
|
||||
expense_date: null,
|
||||
note: null,
|
||||
place_id: null,
|
||||
reservation_id: f.booking.id,
|
||||
payers: [{ user_id: f.guest.id, amount: 50 }],
|
||||
members: [
|
||||
{ user_id: f.owner.id, amount: 30, paid: false },
|
||||
{ user_id: f.guest.id, amount: 20, paid: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
people: [
|
||||
{ user_id: f.owner.id, username: 'ana' },
|
||||
{ user_id: f.guest.id, username: 'ben' },
|
||||
],
|
||||
});
|
||||
// Both rows carry a rate of their own, so the totals never asked for today's.
|
||||
expect(getRates).not.toHaveBeenCalled();
|
||||
// No avatars, receipts or itemised tickets: the REST row's bulk stays behind.
|
||||
expect(JSON.stringify(answer)).not.toMatch(/avatar|receipts|ticket_json/);
|
||||
});
|
||||
|
||||
it('BGI-MCP-011: pages through a long budget while the totals keep covering all of it', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { currency: 'eur' });
|
||||
const ids = await insertRows(
|
||||
orm,
|
||||
BudgetItems,
|
||||
[10, 20, 30].map((price, i) => ({ trip: trip.id, name: `Taxi ${i + 1}`, total_price: price, sort_order: i })),
|
||||
);
|
||||
|
||||
const first = read(await mcp.listBudgetItems({ tripId: trip.id, limit: 2 }, ctx(user.id)));
|
||||
expect(first).toMatchObject({ currency: 'EUR', total: 60, item_count: 3, offset: 0, next_offset: 2 });
|
||||
expect(first.items.map((item: { id: number }) => item.id)).toEqual(ids.slice(0, 2));
|
||||
// A row without a currency is in the trip's, and says so.
|
||||
expect(first.items[0]).toMatchObject({ currency: 'EUR', payers: [], members: [] });
|
||||
expect(first.people).toEqual([]);
|
||||
|
||||
const last = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 2, limit: 2 }, ctx(user.id)));
|
||||
expect(last).toMatchObject({ total: 60, item_count: 3, offset: 2, next_offset: null });
|
||||
expect(last.items.map((item: { id: number }) => item.id)).toEqual([ids[2]]);
|
||||
|
||||
const beyond = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 5 }, ctx(user.id)));
|
||||
expect(beyond).toMatchObject({ item_count: 3, offset: 5, next_offset: null, items: [] });
|
||||
});
|
||||
|
||||
it('BGI-MCP-012: a default page holds 50 expenses and points at the next one', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
await insertRows(
|
||||
orm,
|
||||
BudgetItems,
|
||||
Array.from({ length: 51 }, (_, i) => ({ trip: trip.id, name: `Coffee ${i}`, total_price: 2, sort_order: i })),
|
||||
);
|
||||
|
||||
const page = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id)));
|
||||
expect(page.items).toHaveLength(50);
|
||||
expect(page).toMatchObject({ item_count: 51, next_offset: 50, total: 102 });
|
||||
});
|
||||
|
||||
it('BGI-MCP-013: a foreign expense no rate converts is named and left out of every total', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { currency: 'EUR' });
|
||||
await makeBudgetItem(orm, trip.id, { name: 'Hotel', category: 'accommodation', total_price: 200, sort_order: 1 });
|
||||
// Rate 1 on a foreign row: never frozen, and the server has no quote today.
|
||||
const pho = await makeBudgetItem(orm, trip.id, {
|
||||
name: 'Pho',
|
||||
category: 'food',
|
||||
total_price: 120_000,
|
||||
currency: 'VND',
|
||||
exchange_rate: 1,
|
||||
sort_order: 2,
|
||||
});
|
||||
|
||||
const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id)));
|
||||
expect(answer).toMatchObject({ total: 200, by_category: { accommodation: 200 }, unconverted_item_ids: [pho.id] });
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
});
|
||||
|
||||
it('BGI-MCP-014: someone who cannot open the trip gets the trip refusal and no row', async () => {
|
||||
const f = await fixture();
|
||||
const list = vi.spyOn(t.get(BudgetService), 'listBudgetItems');
|
||||
|
||||
const refused = read(await mcp.listBudgetItems({ tripId: f.trip.id }, ctx(f.outsider.id)));
|
||||
|
||||
expect(refused).toEqual({ error: 'Trip not found or access denied.' });
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BGI-MCP-015: an expense handed over without its split or payers reads as shared by nobody', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
const bare: BudgetItem = { id: 7, trip_id: trip.id, category: 'other', name: 'Tip', total_price: 5, sort_order: 0 };
|
||||
vi.spyOn(t.get(BudgetService), 'listBudgetItems').mockResolvedValue([bare]);
|
||||
|
||||
const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id)));
|
||||
|
||||
expect(answer.items).toEqual([
|
||||
expect.objectContaining({ id: 7, place_id: null, reservation_id: null, payers: [], members: [] }),
|
||||
]);
|
||||
expect(answer.people).toEqual([]);
|
||||
});
|
||||
|
||||
it('BGI-MCP-016: a page of long expenses ends before the assistant cuts the answer, and next_offset starts at the first one it left out', async () => {
|
||||
const { user } = await makeUser(orm, { username: 'organiser' });
|
||||
const trip = await makeTrip(orm, user.id, { currency: 'EUR' });
|
||||
const others = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, i) => makeUser(orm, { username: `traveller-${i}` })),
|
||||
);
|
||||
const travellers = [user.id, ...others.map((other) => other.user.id)];
|
||||
const name = (i: number) => `Dinner ${i} ${'at the long table by the harbour, '.repeat(6)}`;
|
||||
const ids = await insertRows(
|
||||
orm,
|
||||
BudgetItems,
|
||||
Array.from({ length: 100 }, (_, i) => ({ trip: trip.id, name: name(i), total_price: 50, sort_order: i })),
|
||||
);
|
||||
await insertRows(
|
||||
orm,
|
||||
BudgetItemMembers,
|
||||
ids.flatMap((id) => travellers.map((traveller) => ({ budgetItem: id, user: traveller, amount: 10 }))),
|
||||
);
|
||||
|
||||
/** Every page from `offset` on, each checked as the provider would get it. */
|
||||
const pagesFrom = async (offset: number): Promise<number[][]> => {
|
||||
const answer = read(await mcp.listBudgetItems({ tripId: trip.id, offset, limit: 100 }, ctx(user.id)));
|
||||
// What the provider gets is the page whole: nothing cut, nothing the model would never see.
|
||||
expect(toolOutputForProvider('list_budget_items', answer)).toEqual(answer);
|
||||
const page: number[] = answer.items.map((item: { id: number }) => item.id);
|
||||
if (answer.next_offset === null) return [page];
|
||||
expect(answer.next_offset).toBe(offset + page.length);
|
||||
return [page, ...(await pagesFrom(answer.next_offset))];
|
||||
};
|
||||
const pages = await pagesFrom(0);
|
||||
|
||||
// Asked for all hundred at once, the expenses came in more than one page and none was skipped.
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
expect(pages.flat()).toEqual(ids);
|
||||
});
|
||||
|
||||
it('BGI-MCP-017: one expense longer than a page on its own still makes a page, so the list never stalls', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
const ids = await insertRows(orm, BudgetItems, [
|
||||
{ trip: trip.id, name: 'Charter '.repeat(5_000), total_price: 900, sort_order: 0 },
|
||||
{ trip: trip.id, name: 'Fuel', total_price: 80, sort_order: 1 },
|
||||
]);
|
||||
|
||||
const first = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id)));
|
||||
expect(first.items.map((item: { id: number }) => item.id)).toEqual([ids[0]]);
|
||||
expect(first.next_offset).toBe(1);
|
||||
const second = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 1 }, ctx(user.id)));
|
||||
expect(second).toMatchObject({ next_offset: null, items: [expect.objectContaining({ id: ids[1] })] });
|
||||
});
|
||||
|
||||
it('BGI-MCP-018: a long note keeps its start on one line, and a trip without a currency reads in euros', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { currency: null });
|
||||
await makeBudgetItem(orm, trip.id, { name: 'Ferry', total_price: 30, note: `Deck seats\n${'x'.repeat(400)}` });
|
||||
|
||||
const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id)));
|
||||
|
||||
expect(answer).toMatchObject({ currency: 'EUR', total: 30, items: [{ currency: 'EUR' }] });
|
||||
const note: string = answer.items[0].note;
|
||||
expect(note).toHaveLength(200);
|
||||
expect(note.startsWith('Deck seats xxx')).toBe(true);
|
||||
expect(note.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_budget_per_person', () => {
|
||||
it('BGI-MCP-020: answers the shares in the trip currency, as the REST route does', async () => {
|
||||
const f = await fixture();
|
||||
|
||||
const answer = read(await mcp.getBudgetPerPerson({ tripId: f.trip.id }, ctx(f.owner.id)));
|
||||
|
||||
expect(answer).toEqual({ summary: await t.get(BudgetService).perPersonSummary(f.trip.id) });
|
||||
// Ana: 45 of the dinner, paid, and 30 USD of the museum at 1.25, not paid.
|
||||
// Ben: 45 of the dinner, paid, and 20 USD of the museum at 1.25, paid.
|
||||
expect(answer.summary).toEqual([
|
||||
expect.objectContaining({
|
||||
user_id: f.owner.id,
|
||||
total_assigned: 69,
|
||||
total_paid: 45,
|
||||
items_count: 2,
|
||||
currency: 'EUR',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
user_id: f.guest.id,
|
||||
total_assigned: 61,
|
||||
total_paid: 61,
|
||||
items_count: 2,
|
||||
currency: 'EUR',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('BGI-MCP-021: someone who cannot open the trip gets the trip refusal', async () => {
|
||||
const f = await fixture();
|
||||
const summary = vi.spyOn(t.get(BudgetService), 'perPersonSummary');
|
||||
|
||||
expect(read(await mcp.getBudgetPerPerson({ tripId: f.trip.id }, ctx(f.outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
expect(summary).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* DawarichAtlasMcp (DAWARICH-ATLAS-MCP-001..014): the bucket list and the
|
||||
* Atlas checked against the Dawarich recordings over MCP, the twins of
|
||||
* /api/integrations/dawarich/bucket-list/* and /atlas/*.
|
||||
*
|
||||
* The subject comes out of the DawarichModule container over a snapshot test
|
||||
* db, so the wishes, the ticks and the Atlas marks are real rows and the
|
||||
* scoping to the caller is the repositories' own. Only the two seams that
|
||||
* would leave the process are stubbed: DawarichService hands out the stored
|
||||
* credentials (their encryption is its own suite's business), and
|
||||
* DawarichClient answers for the instance, so nothing reaches the network.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { BucketList } from '../../../src/db/entities/BucketList.entity';
|
||||
import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity';
|
||||
import { trekMcpErrorMapper } from '../../../src/mcp/nest-mcp-policy';
|
||||
import { errorResult, type McpContext, type McpTextResult, type ToolOptions } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { DawarichAtlasMcp } from '../../../src/nest/integrations/dawarich-atlas.mcp';
|
||||
import { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service';
|
||||
import { DawarichClient, DawarichError } from '../../../src/nest/integrations/dawarich.client';
|
||||
import { DawarichModule } from '../../../src/nest/integrations/dawarich.module';
|
||||
import { DawarichService } from '../../../src/nest/integrations/dawarich.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeBucketListItem, markCountryVisited } from '../../helpers/factories/atlas';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { callGatedTool } from '../../helpers/mcp-gate';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { z, type ZodRawShape } from 'zod';
|
||||
|
||||
const CREDS = { baseUrl: 'https://dawarich.example.com', apiKey: 'test-key', allowInsecureTls: false };
|
||||
|
||||
const dawarichStub = { getCredentials: vi.fn() };
|
||||
const clientStub = { findVisitsNear: vi.fn(), listVisitedCities: vi.fn() };
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: DawarichAtlasMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [DawarichModule],
|
||||
overrides: [
|
||||
{ provide: DawarichService, useValue: dawarichStub },
|
||||
{ provide: DawarichClient, useValue: clientStub },
|
||||
],
|
||||
});
|
||||
mcp = t.get(DawarichAtlasMcp);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
dawarichStub.getCredentials.mockReset().mockResolvedValue(CREDS);
|
||||
clientStub.findVisitsNear.mockReset().mockResolvedValue([]);
|
||||
clientStub.listVisitedCities.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId, scopes: null, isStaticToken: false }) as McpContext;
|
||||
|
||||
function read(result: McpTextResult) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
function toolOptions(method: string): ToolOptions {
|
||||
const entry = getEntry(DawarichAtlasMcp as unknown as ClassRef, method);
|
||||
if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`);
|
||||
return entry.options;
|
||||
}
|
||||
|
||||
function inputSchema(method: string) {
|
||||
return z.object(toolOptions(method).inputSchema as ZodRawShape);
|
||||
}
|
||||
|
||||
/** A stay of the given length, the given distance off a wish, starting at `at`. */
|
||||
function stay(at: string, minutes: number, distance: number) {
|
||||
return {
|
||||
timestamp: 1_757_000_000,
|
||||
distance_meters: distance,
|
||||
points_count: 30,
|
||||
visit_details: { start_time: at, duration_minutes: minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/** The owner's wishes: two with coordinates, one without; and a stranger's wish next to them. */
|
||||
async function fixture() {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const alhambra = await makeBucketListItem(orm, owner.id, { name: 'Alhambra', lat: 37.176, lng: -3.588 });
|
||||
const petra = await makeBucketListItem(orm, owner.id, { name: 'Petra', lat: 30.328, lng: 35.444 });
|
||||
await makeBucketListItem(orm, owner.id, { name: 'Somewhere warm' });
|
||||
const theirs = await makeBucketListItem(orm, stranger.id, { name: 'Kyoto', lat: 35.011, lng: 135.768 });
|
||||
return { owner, stranger, alhambra, petra, theirs };
|
||||
}
|
||||
|
||||
describe('DawarichAtlasMcp surface', () => {
|
||||
it('DAWARICH-ATLAS-MCP-001: exactly the five bucket-list and Atlas tools, none asking for a credential', () => {
|
||||
const methods = Object.getOwnPropertyNames(DawarichAtlasMcp.prototype).filter(
|
||||
(name) => getEntry(DawarichAtlasMcp as unknown as ClassRef, name) !== undefined,
|
||||
);
|
||||
expect(methods.map((method) => toolOptions(method).name).sort()).toEqual([
|
||||
'accept_atlas_countries',
|
||||
'clear_bucket_list_visit',
|
||||
'confirm_bucket_list_visits',
|
||||
'scan_bucket_list_visits',
|
||||
'suggest_atlas_countries',
|
||||
]);
|
||||
for (const method of methods) {
|
||||
expect(Object.keys(toolOptions(method).inputSchema ?? {}).filter((key) => /url|key|token/i.test(key))).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['scanBucketList', 'read', true, true, true],
|
||||
['suggestAtlasCountries', 'read', true, true, true],
|
||||
['confirmBucketVisits', 'write', false, true, false],
|
||||
['clearBucketVisit', 'write', false, true, false],
|
||||
['acceptAtlasCountries', 'write', false, true, false],
|
||||
])(
|
||||
'DAWARICH-ATLAS-MCP-002: %s claims atlas:%s and advertises readOnly=%s idempotent=%s openWorld=%s',
|
||||
(method, mode, readOnly, idempotent, openWorld) => {
|
||||
const options = toolOptions(method);
|
||||
expect(options.access).toEqual({ group: 'atlas', mode });
|
||||
expect(options.annotations).toMatchObject({
|
||||
readOnlyHint: readOnly,
|
||||
idempotentHint: idempotent,
|
||||
openWorldHint: openWorld,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-003: every tool rides the Dawarich addon, and clearing a visit is announced as a removal', async () => {
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false);
|
||||
const methods = ['scanBucketList', 'confirmBucketVisits', 'clearBucketVisit', 'acceptAtlasCountries'];
|
||||
expect(await Promise.all(methods.map((method) => toolOptions(method).when?.(ctx(1), mcp)))).toEqual(
|
||||
methods.map(() => false),
|
||||
);
|
||||
expect(enabled).toHaveBeenCalledWith(ADDON_IDS.DAWARICH);
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await toolOptions('suggestAtlasCountries').when?.(ctx(1), mcp)).toBe(true);
|
||||
expect(toolOptions('clearBucketVisit').annotations).toMatchObject({ destructiveHint: true });
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-004: the inputs are the route bodies: 1 to 200 wish ids, one wish, 1 to 300 two-letter codes, both window ends', () => {
|
||||
expect(inputSchema('scanBucketList').safeParse({}).success).toBe(true);
|
||||
const confirm = inputSchema('confirmBucketVisits');
|
||||
expect(confirm.safeParse({ itemIds: [1, 2], visitedAt: '2024-04-02' }).success).toBe(true);
|
||||
expect(confirm.safeParse({ itemIds: [] }).success).toBe(false);
|
||||
expect(confirm.safeParse({ itemIds: Array.from({ length: 201 }, (_, i) => i + 1) }).success).toBe(false);
|
||||
expect(confirm.safeParse({ itemIds: [0] }).success).toBe(false);
|
||||
expect(inputSchema('clearBucketVisit').safeParse({ itemId: 3 }).success).toBe(true);
|
||||
expect(inputSchema('clearBucketVisit').safeParse({ itemId: -3 }).success).toBe(false);
|
||||
const accept = inputSchema('acceptAtlasCountries');
|
||||
expect(accept.safeParse({ countryCodes: ['PT', 'es'] }).success).toBe(true);
|
||||
expect(accept.safeParse({ countryCodes: ['PRT'] }).success).toBe(false);
|
||||
expect(accept.safeParse({ countryCodes: [] }).success).toBe(false);
|
||||
const window = inputSchema('suggestAtlasCountries');
|
||||
expect(window.safeParse({ from: '2026-01-01', to: '2026-02-01' }).success).toBe(true);
|
||||
expect(window.safeParse({ from: '2026-01-01' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-005: the two reads pass the demo gate, the three writes are refused for the demo account and write nothing', async () => {
|
||||
const f = await fixture();
|
||||
const isDemoUser = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const call = ([method, args]: [string, unknown]) => callGatedTool(mcp, method, args, ctx(f.owner.id), isDemoUser);
|
||||
|
||||
const reads: [string, unknown][] = [
|
||||
['scanBucketList', {}],
|
||||
['suggestAtlasCountries', { from: '2026-01-01', to: '2026-02-01' }],
|
||||
];
|
||||
const answered = (await Promise.all(reads.map(call))) as McpTextResult[];
|
||||
expect(answered.map((answer) => answer.isError)).toEqual([undefined, undefined]);
|
||||
expect(isDemoUser).not.toHaveBeenCalled();
|
||||
|
||||
const writes: [string, unknown][] = [
|
||||
['confirmBucketVisits', { itemIds: [f.alhambra.id] }],
|
||||
['clearBucketVisit', { itemId: f.alhambra.id }],
|
||||
['acceptAtlasCountries', { countryCodes: ['PT'] }],
|
||||
];
|
||||
expect(await Promise.all(writes.map(call))).toEqual(
|
||||
writes.map(() => errorResult('Write operations are disabled in demo mode.')),
|
||||
);
|
||||
expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBeNull();
|
||||
expect(await countRows(orm, VisitedCountries, { user: f.owner.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan_bucket_list_visits', () => {
|
||||
it("DAWARICH-ATLAS-MCP-006: asks the recordings about the caller's own wishes and ticks nothing off", async () => {
|
||||
const f = await fixture();
|
||||
clientStub.findVisitsNear.mockImplementation(async (_creds: unknown, lat: number) =>
|
||||
lat === 37.176 ? [stay('2024-04-02T10:00:00+02:00', 95, 40)] : [],
|
||||
);
|
||||
|
||||
const answer = read(await mcp.scanBucketList({}, ctx(f.owner.id)));
|
||||
|
||||
// Newest wish first, as the Atlas lists them.
|
||||
expect(answer).toMatchObject({
|
||||
matches: [
|
||||
{ itemId: f.petra.id, name: 'Petra', match: null, alreadyVisited: false },
|
||||
{
|
||||
itemId: f.alhambra.id,
|
||||
name: 'Alhambra',
|
||||
match: { at: '2024-04-02T10:00:00+02:00', minutes: 95, distanceMeters: 40, points: 30 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
],
|
||||
skippedWithoutCoordinates: 1,
|
||||
truncated: false,
|
||||
});
|
||||
// The stranger's wish is never asked about.
|
||||
expect(clientStub.findVisitsNear.mock.calls.map((call) => call[1])).toEqual([30.328, 37.176]);
|
||||
expect(dawarichStub.getCredentials).toHaveBeenCalledWith(f.owner.id);
|
||||
expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBeNull();
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-007: without a connection the scan is the route refusal, and nothing is asked', async () => {
|
||||
const f = await fixture();
|
||||
dawarichStub.getCredentials.mockResolvedValue(null);
|
||||
|
||||
expect(await mcp.scanBucketList({}, ctx(f.owner.id))).toEqual(errorResult('Dawarich is not connected'));
|
||||
expect(clientStub.findVisitsNear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-008: an instance that does not answer ends the scan after one lookup, in TREK words', async () => {
|
||||
const f = await fixture();
|
||||
clientStub.findVisitsNear.mockRejectedValue(
|
||||
new DawarichError('unreachable', 'Could not reach Dawarich', undefined, 'connect ETIMEDOUT 10.0.0.5:443'),
|
||||
);
|
||||
|
||||
// The upstream detail stays behind, as it does on the route.
|
||||
expect(await mcp.scanBucketList({}, ctx(f.owner.id))).toEqual(errorResult('Could not reach Dawarich'));
|
||||
expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggest_atlas_countries', () => {
|
||||
it('DAWARICH-ATLAS-MCP-009: reads the window as instants and marks the countries the Atlas has already', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
await markCountryVisited(orm, user.id, 'ES');
|
||||
clientStub.listVisitedCities.mockResolvedValue([
|
||||
{ country: 'Portugal', cities: [{ city: 'Lisbon', stayed_for: 300, timestamp: 1_767_225_600 }] },
|
||||
{ country: 'Spain', cities: [{ city: 'Seville', stayed_for: 90 }] },
|
||||
{ country: 'Atlantis', cities: [] },
|
||||
]);
|
||||
|
||||
const answer = read(
|
||||
await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01T00:00:00Z' }, ctx(user.id)),
|
||||
);
|
||||
|
||||
expect(clientStub.listVisitedCities).toHaveBeenCalledWith(
|
||||
CREDS,
|
||||
new Date('2026-01-01T00:00:00.000Z'),
|
||||
new Date('2026-02-01T00:00:00.000Z'),
|
||||
);
|
||||
expect(answer).toMatchObject({
|
||||
countries: [
|
||||
{
|
||||
countryCode: 'PT',
|
||||
sourceName: 'Portugal',
|
||||
cities: [{ name: 'Lisbon', minutes: 300, lastSeenAt: '2026-01-01T00:00:00.000Z' }],
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{ countryCode: 'ES', sourceName: 'Spain', alreadyVisited: true },
|
||||
],
|
||||
unresolved: ['Atlantis'],
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-010: the assistant hands the provider the cities and minutes, never when the user was where', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
clientStub.listVisitedCities.mockResolvedValue([
|
||||
{ country: 'Portugal', cities: [{ city: 'Lisbon', stayed_for: 300, timestamp: 1_767_225_600 }] },
|
||||
]);
|
||||
const answer = read(await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01' }, ctx(user.id)));
|
||||
|
||||
const sent = toolOutputForProvider('suggest_atlas_countries', answer) as typeof answer;
|
||||
|
||||
expect(sent.countries).toEqual([
|
||||
{ countryCode: 'PT', sourceName: 'Portugal', cities: [{ name: 'Lisbon', minutes: 300 }], alreadyVisited: false },
|
||||
]);
|
||||
expect(sent).toMatchObject({ unresolved: [], fetchedAt: answer.fetchedAt });
|
||||
expect(JSON.stringify(sent)).not.toContain('lastSeenAt');
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-011: an instance that fails is a refusal in TREK words', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
clientStub.listVisitedCities.mockRejectedValue(
|
||||
new DawarichError('server_error', 'Dawarich answered HTTP 500', 500),
|
||||
);
|
||||
|
||||
expect(await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01' }, ctx(user.id))).toEqual(
|
||||
errorResult('Dawarich answered HTTP 500'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ from: '2026-02-01', to: '2026-01-01' }, 'to must be after from'],
|
||||
[{ from: 'soon', to: '2026-01-01' }, 'from and to are required ISO timestamps'],
|
||||
[{ from: '2024-01-01', to: '2026-01-01' }, 'Window must be 400 days or less'],
|
||||
])(
|
||||
'DAWARICH-ATLAS-MCP-012: the window %j answers the route 400 sentence (%s) as the refusal, before the instance is asked',
|
||||
async (window, message) => {
|
||||
const { user } = await makeUser(orm);
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await mcp.suggestAtlasCountries(window, ctx(user.id));
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(DomainError);
|
||||
expect(thrown).toMatchObject({ status: 400, publicMessage: message });
|
||||
// What the registry answers the call with.
|
||||
expect(trekMcpErrorMapper(thrown)).toEqual(errorResult(message));
|
||||
expect(clientStub.listVisitedCities).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('the writes', () => {
|
||||
it("DAWARICH-ATLAS-MCP-013: ticks off and clears only the caller's own wishes, as the routes do", async () => {
|
||||
const f = await fixture();
|
||||
|
||||
const confirmed = read(
|
||||
await mcp.confirmBucketVisits(
|
||||
{ itemIds: [f.alhambra.id, f.theirs.id], visitedAt: '2024-04-02T10:00:00Z' },
|
||||
ctx(f.owner.id),
|
||||
),
|
||||
);
|
||||
expect(confirmed).toEqual({ updated: 1 });
|
||||
expect(await findRow(orm, BucketList, { id: f.alhambra.id })).toMatchObject({
|
||||
visited_at: '2024-04-02T10:00:00Z',
|
||||
visited_source: 'dawarich',
|
||||
});
|
||||
expect((await findRow(orm, BucketList, { id: f.theirs.id }))?.visited_at).toBeNull();
|
||||
// Ticked off already: it keeps its date.
|
||||
expect(read(await mcp.confirmBucketVisits({ itemIds: [f.alhambra.id] }, ctx(f.owner.id)))).toEqual({ updated: 0 });
|
||||
|
||||
expect(await mcp.clearBucketVisit({ itemId: f.alhambra.id }, ctx(f.stranger.id))).toEqual(
|
||||
errorResult('Bucket-list entry not found'),
|
||||
);
|
||||
expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBe('2024-04-02T10:00:00Z');
|
||||
expect(read(await mcp.clearBucketVisit({ itemId: f.alhambra.id }, ctx(f.owner.id)))).toEqual({ success: true });
|
||||
expect(await findRow(orm, BucketList, { id: f.alhambra.id })).toMatchObject({
|
||||
visited_at: null,
|
||||
visited_source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('DAWARICH-ATLAS-MCP-014: marks confirmed countries once with their source, and a failure that is no refusal propagates', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
|
||||
expect(read(await mcp.acceptAtlasCountries({ countryCodes: ['PT', 'es'] }, ctx(user.id)))).toEqual({ marked: 2 });
|
||||
expect(read(await mcp.acceptAtlasCountries({ countryCodes: ['ES'] }, ctx(user.id)))).toEqual({ marked: 0 });
|
||||
expect(await countRows(orm, VisitedCountries, { user: user.id, source: 'dawarich' })).toBe(2);
|
||||
|
||||
vi.spyOn(t.get(DawarichSuggestionsService), 'acceptAtlasCountries').mockRejectedValue(
|
||||
new Error('SQLITE_BUSY: database is locked'),
|
||||
);
|
||||
await expect(mcp.acceptAtlasCountries({ countryCodes: ['FR'] }, ctx(user.id))).rejects.toThrow('SQLITE_BUSY');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Unit tests for DawarichSuggestionsService: DAWARICH-SUG-001..064.
|
||||
* Unit tests for DawarichSuggestionsService: DAWARICH-SUG-001..069.
|
||||
*
|
||||
* This is the only service in the Dawarich domain that writes into TREK proper,
|
||||
* and every write it makes crosses an ownership line: a suggestion belongs to
|
||||
@@ -25,7 +25,7 @@ import type { UsersRepository } from '../../../src/db/repositories/Users.reposit
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { AcceptError, DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service';
|
||||
import type { DawarichClient } from '../../../src/nest/integrations/dawarich.client';
|
||||
import { DawarichError, type DawarichClient } from '../../../src/nest/integrations/dawarich.client';
|
||||
import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
|
||||
import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import type { JourneyEntryStaysService } from '../../../src/nest/journey/journey-entry-stays.service';
|
||||
@@ -1139,6 +1139,39 @@ describe('DawarichSuggestionsService — scanning for stays', () => {
|
||||
expect(scan.matches).toHaveLength(1);
|
||||
expect(scan.matches[0].match).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['unreachable', 'unauthorized', 'forbidden', 'rate_limited'] as const)(
|
||||
'DAWARICH-SUG-068: an instance failing with %s ends the scan at the first wish instead of reporting every wish as missed',
|
||||
async (code) => {
|
||||
const { user } = createUser(testDb);
|
||||
await Promise.all(['Hallstatt', 'Kyoto', 'Petra'].map((name) => seedBucketItem(user.id, { name })));
|
||||
dawarichStub.getCredentials.mockReturnValue(CREDS);
|
||||
const failure = new DawarichError(code, 'Could not reach Dawarich');
|
||||
clientStub.findVisitsNear.mockReset().mockRejectedValue(failure);
|
||||
|
||||
await expect(svc.scanBucketList(user.id)).rejects.toBe(failure);
|
||||
// A hanging instance costs one timeout, not one per wish.
|
||||
expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['server_error', 'not_found', 'invalid_response'] as const)(
|
||||
'DAWARICH-SUG-069: a lookup answered with %s is that one wish missed, and the scan goes on',
|
||||
async (code) => {
|
||||
const { user } = createUser(testDb);
|
||||
await Promise.all(['Hallstatt', 'Kyoto'].map((name) => seedBucketItem(user.id, { name })));
|
||||
dawarichStub.getCredentials.mockReturnValue(CREDS);
|
||||
clientStub.findVisitsNear
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new DawarichError(code, 'Dawarich answered HTTP 500'))
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const scan = await svc.scanBucketList(user.id);
|
||||
|
||||
expect(scan.matches.map((m) => m.match)).toEqual([null, null]);
|
||||
expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Atlas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
minutesBetween,
|
||||
normalizeVisit,
|
||||
offsetMinutesOf,
|
||||
parseTimeWindow,
|
||||
simplify,
|
||||
splitAtGaps,
|
||||
splitWindow,
|
||||
@@ -1327,3 +1328,25 @@ describe('visited cities over a long range', () => {
|
||||
expect(again[0].cities[0]).toEqual({ city: 'Gedser', points: 10, stayed_for: undefined, timestamp: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeWindow', () => {
|
||||
it('DAWARICH-SPAN-001: reads both ends as instants, a bare date as its midnight UTC, up to 400 days apart', () => {
|
||||
expect(parseTimeWindow('2025-01-01', '2026-02-05T00:00:00Z')).toEqual({
|
||||
from: new Date('2025-01-01T00:00:00.000Z'),
|
||||
to: new Date('2026-02-05T00:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string | undefined, string | undefined, string]>([
|
||||
[undefined, '2026-01-01', 'from and to are required ISO timestamps'],
|
||||
['2026-01-01', '', 'from and to are required ISO timestamps'],
|
||||
['2026-01-01', 'later', 'from and to are required ISO timestamps'],
|
||||
['2026-01-01', '2026-01-01', 'to must be after from'],
|
||||
['2025-01-01', '2026-02-06', 'Window must be 400 days or less'],
|
||||
])('DAWARICH-SPAN-002: from %j to %j is a 400 both REST and MCP answer with "%s"', (from, to, message) => {
|
||||
// A DomainError: the route writes it as its { error } body, the MCP registry as the refusal text.
|
||||
expect(() => parseTimeWindow(from, to)).toThrow(
|
||||
expect.objectContaining({ name: 'DomainError', status: 400, publicMessage: message }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* JourneyTracksMcp (JRNTRK-MCP-001..005): the GPX tracks a journey's map draws,
|
||||
* over MCP, on the same service and journey access as GET
|
||||
* /api/journeys/:id/tracks. Each track answers with its size for the model and
|
||||
* a thinned line under display_geometry for an MCP client that draws it, the
|
||||
* lines of all tracks held to about 1000 points together; TREK's assistant
|
||||
* keeps the lines away from the provider.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsModule } from '../../../src/nest/addons/addons.module';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact';
|
||||
import { JourneyDomainModule } from '../../../src/nest/journey/journey-domain.module';
|
||||
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
||||
import { JourneyTracksMcp } from '../../../src/nest/journey/journey-tracks.mcp';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { updateRows } from '../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let orm: TestOrm;
|
||||
let mcp: JourneyTracksMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [JourneyDomainModule, AddonsModule], providers: [JourneyTracksMcp] });
|
||||
mcp = t.get(JourneyTracksMcp);
|
||||
orm = await createTestOrm(db);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
orm.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await orm.close();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
/** A place of the trip carrying a route geometry, drawn in one colour. */
|
||||
async function placeWithGeometry(tripId: number, name: string, points: [number, number][]) {
|
||||
const place = await makePlace(orm, tripId, { name });
|
||||
await updateRows(orm, Places, { id: place.id }, { route_geometry: JSON.stringify(points), route_color: '#d9480f' });
|
||||
return place.id;
|
||||
}
|
||||
|
||||
/** A journey written about one trip, one of whose places carries the given track. */
|
||||
async function journeyWithTrack(name: string, points: [number, number][]) {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Dolomites' });
|
||||
const journey = await makeJourney(orm, user.id);
|
||||
await makeJourneyEntry(orm, journey.id, user.id, { sourceTrip: trip.id });
|
||||
const placeId = await placeWithGeometry(trip.id, name, points);
|
||||
return { user, trip, journey, placeId };
|
||||
}
|
||||
|
||||
describe('get_journey_tracks', () => {
|
||||
it('JRNTRK-MCP-001: answers each track by its trip, name, kind, size and length, with a line to draw', async () => {
|
||||
// Two hundredths of a degree along the equator: 2.22 km.
|
||||
const f = await journeyWithTrack('Ridge', [
|
||||
[0.000001234, 0],
|
||||
[0, 0.01],
|
||||
[0, 0.02],
|
||||
]);
|
||||
// A single point is a pin, no track.
|
||||
await placeWithGeometry(f.trip.id, 'Summit', [[46.5, 11.8]]);
|
||||
|
||||
const answer = read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(f.user.id)));
|
||||
|
||||
expect(answer).toEqual({
|
||||
tracks: [
|
||||
{
|
||||
place_id: f.placeId,
|
||||
trip_id: f.trip.id,
|
||||
name: 'Ridge',
|
||||
kind: 'trip_gpx',
|
||||
color: '#d9480f',
|
||||
point_count: 3,
|
||||
distance_km: 2.2,
|
||||
display_geometry: [
|
||||
[0, 0],
|
||||
[0, 0.01],
|
||||
[0, 0.02],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('JRNTRK-MCP-002: a long track keeps its length and its ends, with at most 200 points to draw, none of which reach the provider', async () => {
|
||||
const points = Array.from({ length: 1000 }, (_, i): [number, number] => [46 + i / 10_000, 11 + i / 10_000]);
|
||||
const f = await journeyWithTrack('Traverse', points);
|
||||
|
||||
const answer = read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(f.user.id)));
|
||||
|
||||
const [track] = answer.tracks;
|
||||
expect(track.point_count).toBe(1000);
|
||||
expect(track.display_geometry).toHaveLength(200);
|
||||
expect(track.display_geometry[0]).toEqual([46, 11]);
|
||||
expect(track.display_geometry[199]).toEqual([46.0999, 11.0999]);
|
||||
expect(track.distance_km).toBeGreaterThan(13);
|
||||
// The line is for an MCP client that draws it; TREK's assistant hands the model the summary alone.
|
||||
expect(toolOutputForProvider('get_journey_tracks', answer)).toEqual({
|
||||
tracks: [
|
||||
{
|
||||
place_id: track.place_id,
|
||||
trip_id: f.trip.id,
|
||||
name: 'Traverse',
|
||||
kind: 'trip_gpx',
|
||||
color: '#d9480f',
|
||||
point_count: 1000,
|
||||
distance_km: track.distance_km,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[8, 1000, 125],
|
||||
[3, 1000, 200],
|
||||
[700, 10, 2],
|
||||
])(
|
||||
'JRNTRK-MCP-005: %i tracks of %i points share the lines: %i points each, both ends kept, about 1000 in all',
|
||||
async (count, length, each) => {
|
||||
const tracks = Array.from({ length: count }, (_, n) => ({
|
||||
place_id: n + 1,
|
||||
trip_id: 1,
|
||||
name: `Track ${n + 1}`,
|
||||
color: null,
|
||||
points: Array.from({ length }, (_, i): [number, number] => [46 + i / 1000, 11 + n / 1000]),
|
||||
}));
|
||||
vi.spyOn(t.get(JourneyDomainService), 'journeyTracks').mockResolvedValue(tracks);
|
||||
|
||||
const answer = read(await mcp.getJourneyTracks({ journeyId: 1 }, ctx(1)));
|
||||
|
||||
type Line = [number, number][];
|
||||
const lines: Line[] = answer.tracks.map((track: { display_geometry: Line }) => track.display_geometry);
|
||||
const rounded = ([lat, lng]: [number, number]) => [Math.round(lat * 1e5) / 1e5, Math.round(lng * 1e5) / 1e5];
|
||||
expect(lines.map((line) => line.length)).toEqual(tracks.map(() => each));
|
||||
expect(lines.map((line) => [line[0], line[line.length - 1]])).toEqual(
|
||||
tracks.map((track) => [rounded(track.points[0]), rounded(track.points[length - 1])]),
|
||||
);
|
||||
expect(lines.flat().length).toBeLessThanOrEqual(Math.max(1000, count * 2));
|
||||
expect(answer.tracks.map((track: { point_count: number }) => track.point_count)).toEqual(
|
||||
tracks.map(() => length),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('JRNTRK-MCP-003: a contributor reads them, a stranger gets the journey refusal', async () => {
|
||||
const f = await journeyWithTrack('Ridge', [
|
||||
[46, 11],
|
||||
[46.01, 11.01],
|
||||
]);
|
||||
const { user: viewer } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
await addJourneyContributor(orm, f.journey.id, viewer.id, 'viewer');
|
||||
|
||||
expect(read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(viewer.id))).tracks).toHaveLength(1);
|
||||
expect(read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(stranger.id)))).toEqual({
|
||||
error: 'Journey not found or access denied.',
|
||||
});
|
||||
});
|
||||
|
||||
it('JRNTRK-MCP-004: is a read of the journey under the Journey addon', async () => {
|
||||
const entry = getEntry(JourneyTracksMcp as unknown as ClassRef, 'getJourneyTracks');
|
||||
if (entry?.kind !== 'tool') throw new Error('getJourneyTracks is no tool');
|
||||
expect(entry.options.name).toBe('get_journey_tracks');
|
||||
expect(entry.options.access).toEqual({ group: 'journey', mode: 'read' });
|
||||
expect(entry.options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
|
||||
const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false);
|
||||
expect(await entry.options.when?.(ctx(1), mcp)).toBe(false);
|
||||
expect(enabled).toHaveBeenCalledWith(ADDON_IDS.JOURNEY);
|
||||
enabled.mockResolvedValue(true);
|
||||
expect(await entry.options.when?.(ctx(1), mcp)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* WeatherMcp get_wind_forecast (WIND-MCP-001..005): the wind for a place on
|
||||
* one day over MCP, the twin of GET /api/weather/wind. The tool comes out of
|
||||
* the container with the real WindService; Open-Meteo is a stubbed fetch, so
|
||||
* nothing leaves the test. The dates are counted from the real today, because
|
||||
* the tool asks the service without a clock of its own.
|
||||
*/
|
||||
import type { McpContext } from '../../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
|
||||
import { WeatherMcp } from '../../../../src/nest/weather/weather.mcp';
|
||||
import { WeatherModule } from '../../../../src/nest/weather/weather.module';
|
||||
import { WIND_UNAVAILABLE_MESSAGE, WindService } from '../../../../src/nest/weather/wind.service';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { windQuerySchema } from '@trek/shared';
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { z, type ZodRawShape } from 'zod';
|
||||
|
||||
// The failed request's warning stays out of the log file; everything else of the logger is real.
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let mcp: WeatherMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [WeatherModule] });
|
||||
mcp = t.get(WeatherMcp);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = { userId: 1 } as McpContext;
|
||||
|
||||
/** The UTC day `days` from today, as YYYY-MM-DD. */
|
||||
function dayFromToday(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
function answer(body: unknown, status = 200) {
|
||||
return vi.fn(
|
||||
async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openMeteo(date: string) {
|
||||
return {
|
||||
timezone: 'Europe/Lisbon',
|
||||
hourly: {
|
||||
time: [`${date}T09:00`, `${date}T15:00`],
|
||||
wind_speed_10m: [12.4, 27.6],
|
||||
wind_gusts_10m: [20.2, null],
|
||||
wind_direction_10m: [315, 290.6],
|
||||
},
|
||||
daily: { wind_speed_10m_max: [27.6], wind_gusts_10m_max: [44.8], wind_direction_10m_dominant: [300] },
|
||||
};
|
||||
}
|
||||
|
||||
function toolOptions() {
|
||||
const entry = getEntry(WeatherMcp as unknown as ClassRef, 'getWindForecast');
|
||||
if (entry?.kind !== 'tool') throw new Error('getWindForecast is no tool');
|
||||
return entry.options;
|
||||
}
|
||||
|
||||
describe('get_wind_forecast', () => {
|
||||
it('WIND-MCP-001: is a read of the weather for everyone signed in, on the input the REST query takes', () => {
|
||||
const options = toolOptions();
|
||||
expect(options.name).toBe('get_wind_forecast');
|
||||
expect(options.access).toEqual({ group: 'weather', mode: 'read' });
|
||||
expect(options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false });
|
||||
// Weather has no addon, like the route behind JwtAuthGuard alone.
|
||||
expect(options.when).toBeUndefined();
|
||||
expect(options.inputSchema).toEqual(windQuerySchema.shape);
|
||||
const input = z.object(options.inputSchema as ZodRawShape);
|
||||
expect(input.safeParse({ lat: 38.7, lng: -9.1, date: '2026-10-21' }).success).toBe(true);
|
||||
expect(input.safeParse({ lat: 91, lng: -9.1, date: '2026-10-21' }).success).toBe(false);
|
||||
expect(input.safeParse({ lat: 38.7, lng: -9.1, date: '21.10.2026' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('WIND-MCP-002: answers the hours and the day of the forecast under wind', async () => {
|
||||
const date = dayFromToday(2);
|
||||
const fetchSpy = answer(openMeteo(date));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const result = read(await mcp.getWindForecast({ lat: 38.71, lng: -9.14, date }, ctx));
|
||||
|
||||
expect(result.wind).toMatchObject({
|
||||
status: 'forecast',
|
||||
date,
|
||||
lat: 38.71,
|
||||
lng: -9.14,
|
||||
timezone: 'Europe/Lisbon',
|
||||
source: 'Open-Meteo',
|
||||
hours: [
|
||||
{ hour: 9, speed_kmh: 12, gusts_kmh: 20, direction_deg: 315 },
|
||||
{ hour: 15, speed_kmh: 28, gusts_kmh: null, direction_deg: 291 },
|
||||
],
|
||||
day: { speed_max_kmh: 28, gusts_max_kmh: 45, direction_dominant_deg: 300 },
|
||||
});
|
||||
expect(String(fetchSpy.mock.calls[0]?.[0 as never])).toContain('latitude=38.71&longitude=-9.14');
|
||||
});
|
||||
|
||||
it('WIND-MCP-003: a day gone and a day past the forecast are named without figures and without a request', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const past = read(await mcp.getWindForecast({ lat: 40, lng: -8, date: dayFromToday(-3) }, ctx));
|
||||
const far = read(await mcp.getWindForecast({ lat: 40, lng: -8, date: dayFromToday(40) }, ctx));
|
||||
|
||||
expect([past.wind.status, far.wind.status]).toEqual(['past', 'beyond_horizon']);
|
||||
expect([past.wind.hours, far.wind.day]).toEqual([[], null]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WIND-MCP-004: a source that fails is a refusal with the route sentence, not a transport error', async () => {
|
||||
vi.stubGlobal('fetch', answer({ error: true }, 503));
|
||||
|
||||
const result = await mcp.getWindForecast({ lat: 41.15, lng: -8.61, date: dayFromToday(1) }, ctx);
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(read(result)).toEqual({ error: WIND_UNAVAILABLE_MESSAGE });
|
||||
});
|
||||
|
||||
it('WIND-MCP-005: a failure without a message of its own still reads as a sentence', async () => {
|
||||
vi.spyOn(t.get(WindService), 'forecast').mockRejectedValueOnce('timeout');
|
||||
|
||||
const result = await mcp.getWindForecast({ lat: 37.02, lng: -7.93, date: dayFromToday(1) }, ctx);
|
||||
|
||||
expect(read(result)).toEqual({ error: 'Weather service not available.' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user