/** * Unit tests for MCP atlas expanded tools (atlas addon-gated): * get_atlas_stats, list_visited_regions, locate_atlas_region, mark_region_visited, * unmark_region_visited, get_country_atlas_places, update_bucket_list_item. * Also covers resources trek://atlas/stats and trek://atlas/regions. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { db as testDb } from '../../../src/db/database'; vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { resetTestDb } from '../../helpers/test-db'; import { createUser, createBucketListItem, createVisitedCountry, createTrip, createReservation } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; import { setAddonEnabled } from '../../helpers/test-db'; import { ADDON_IDS } from '../../../src/addons'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { countRows, findRow, insertRow, upsertRow } from '../../helpers/factories/rows'; import { makePlace } from '../../helpers/factories/places'; import { makeTrip } from '../../helpers/factories/trips'; import { markCountryVisited } from '../../helpers/factories/atlas'; import { BucketList } from '../../../src/db/entities/BucketList.entity'; import { HiddenRegions } from '../../../src/db/entities/HiddenRegions.entity'; import { PlaceRegions } from '../../../src/db/entities/PlaceRegions.entity'; import { Places } from '../../../src/db/entities/Places.entity'; import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; import { VisitedRegions } from '../../../src/db/entities/VisitedRegions.entity'; beforeEach(() => { setAddonEnabled(testDb, ADDON_IDS.ATLAS, true); resetTestDb(testDb); broadcastMock.mockClear(); delete process.env.DEMO_MODE; }); let orm: TestOrm; beforeAll(async () => { orm = await createTestOrm(testDb); }); afterAll(async () => { await orm.close(); testDb.close(); }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false }); try { await fn(h); } finally { await h.cleanup(); } } // The atlas resources register via the nest-mcp registry inside registerTools // (AtlasMcp @Resource), so the harness must keep tools on for them to attach // (same shape as tools-vacay.test.ts's withResourceHarness). async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: true }); try { await fn(h); } finally { await h.cleanup(); } } // A place carrying an address and coordinates, which createPlace does not write. async function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): Promise { return (await makePlace(orm, tripId, { name, address, lat, lng, category: null })).id; } // The geocoder's answer for a place, pre-seeded so visitedRegions() reads the cache // and never reaches for Nominatim (same trick as ATLAS-UNIT-020). async function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): Promise { await upsertRow(orm, PlaceRegions, { place: placeId, country_code: countryCode, region_code: regionCode, region_name: regionName }); } async function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): Promise { await insertRow(orm, ReservationEndpoints, { reservation: reservationId, role, sequence, name: `Endpoint ${sequence}`, lat, lng }); } /** Marks a region by hand, the way the region route stores it. */ async function markRegion(userId: number, regionCode: string, regionName: string, countryCode: string): Promise { await insertRow(orm, VisitedRegions, { user: userId, region_code: regionCode, region_name: regionName, country_code: countryCode }); } /** A wish with no coordinates; returns its id. */ async function insertWish(userId: number, name: string, targetDate?: string): Promise { return insertRow(orm, BucketList, { user: userId, name, ...(targetDate !== undefined ? { target_date: targetDate } : { lat: null, lng: null }), }); } // Trips have to sit in the past for their countries and regions to count as // visited rather than planned (#1048). const PAST_START = '2023-05-01'; const PAST_END = '2023-05-10'; // --------------------------------------------------------------------------- // get_atlas_stats // --------------------------------------------------------------------------- describe('Tool: get_atlas_stats', () => { it('returns stats object without error for empty data', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result) as any; expect(data.stats).toBeDefined(); expect(data.travel).toEqual({ countries: [], cities: [], totalTrips: 0, totalDays: 0, totalPlaces: 0, totalDistanceKm: 0, }); }); }); it('carries the passport figures REST answers with: the cities by name and the distance flown', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); const flight = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' }); await insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389); await insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} }); const data = parseToolResult(result) as any; // The count was always there; the names behind it were not. expect(data.stats.stats.totalCities).toBe(1); expect(data.travel.cities).toEqual(['Rome']); expect(data.travel.countries).toContain('IT'); expect(data.travel.countries).toContain('US'); // Rome to New York, so a four-figure number rather than a rounding artefact. expect(data.travel.totalDistanceKm).toBeGreaterThan(6000); expect(data.travel.totalPlaces).toBe(await countRows(orm, Places, { trip: trip.id })); // Rendering data stays out unless asked for. expect(data.travel.coords).toBeUndefined(); }); }); it('include_coords adds the per-place coordinates the dashboard map plots', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); await insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922); await insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: true } }); const data = parseToolResult(result) as any; const withCoords = await countRows(orm, Places, { trip: trip.id, lat: { $ne: null }, lng: { $ne: null } }); expect(data.travel.coords).toHaveLength(withCoords); expect(data.travel.coords[0]).toEqual({ lat: 41.8902, lng: 12.4922 }); }); }); it('refuses a non-boolean include_coords', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: 'yes' } }); expect(result.isError).toBe(true); expect((result.content[0] as any).text).toContain('include_coords'); }); }); }); // --------------------------------------------------------------------------- // list_visited_regions // --------------------------------------------------------------------------- describe('Tool: list_visited_regions', () => { it('returns an empty grouping initially', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); const data = parseToolResult(result) as any; expect(data.regions).toEqual({}); }); }); it('groups a manual mark under its country, in the shape the map reads', async () => { const { user } = createUser(testDb); await markRegion(user.id, 'FR-75', 'Paris', 'FR'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); const data = parseToolResult(result) as any; expect(data.regions.FR).toEqual([ { code: 'FR-75', name: 'Paris', placeCount: 0, status: 'visited', manuallyMarked: true }, ]); }); }); it('reports regions derived from trip places, which the manual table never held', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); await cacheRegion(await insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); const data = parseToolResult(result) as any; expect(data.regions.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 2, status: 'visited' }]); // Nothing was ever marked by hand, so the old manual-only read had no row // to answer with at all. expect(await countRows(orm, VisitedRegions, { user: user.id })).toBe(0); }); }); it('drops a region the user dismissed, matching the map', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); await withHarness(user.id, async (h) => { const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } }); expect(parseToolResult(unmark)).toEqual({ success: true }); const tombstone = await findRow(orm, HiddenRegions, { user: user.id, region_code: 'IT-62' }); expect(tombstone).toBeTruthy(); const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); const data = parseToolResult(result) as any; expect(data.regions.IT).toBeUndefined(); }); }); }); // --------------------------------------------------------------------------- // locate_atlas_region // --------------------------------------------------------------------------- describe('Tool: locate_atlas_region', () => { it('resolves a coordinate to the country and the region the map can highlight', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 41.9028, lng: 12.4964 }, }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result) as any; // Rome. Italy has admin1 coverage in the bundle, so the region resolves too, // and the code comes back in the form mark_region_visited takes. expect(data.country_code).toBe('IT'); expect(typeof data.region_code).toBe('string'); expect(typeof data.region_name).toBe('string'); }); }); it('feeds mark_region_visited without the caller knowing any codes', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const located = parseToolResult( await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 41.9028, lng: 12.4964 } }), ) as any; expect(typeof located.region_code).toBe('string'); await h.client.callTool({ name: 'mark_region_visited', arguments: { regionCode: located.region_code, regionName: located.region_name, countryCode: located.country_code, }, }); const row = await findRow(orm, VisitedRegions, { user: user.id }); expect({ region_code: row?.region_code, country_code: row?.country_code }).toEqual({ region_code: located.region_code, country_code: 'IT', }); }); }); it('answers with nulls out at sea rather than failing', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 0, lng: -140 } }); expect(result.isError).toBeFalsy(); expect(parseToolResult(result)).toEqual({ country_code: null, region_code: null, region_name: null }); }); }); it('refuses an out-of-range coordinate, like the REST route does', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const badLat = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 91, lng: 12.5 } }); expect(badLat.isError).toBe(true); expect((badLat.content[0] as any).text).toContain('lat'); const badLng = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 41.9, lng: 181 } }); expect(badLng.isError).toBe(true); expect((badLng.content[0] as any).text).toContain('lng'); }); }); }); // --------------------------------------------------------------------------- // mark_region_visited // --------------------------------------------------------------------------- describe('Tool: mark_region_visited', () => { it('uppercases both codes like REST does (post-fold quirk fix)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'mark_region_visited', arguments: { regionCode: 'jp-13', regionName: 'Tokyo', countryCode: 'jp' }, }); const data = parseToolResult(result) as any; // Legacy stored 'jp-13'/'jp' verbatim, creating rows REST's uppercased // unmark could never hit; both codes now normalize. expect(data.region.code).toBe('JP-13'); expect(data.region.country_code).toBe('JP'); const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'jp-13' }, }); expect(parseToolResult(unmark)).toEqual({ success: true }); expect(await findRow(orm, VisitedRegions, { user: user.id })).toBeNull(); }); }); it('inserts region and returns region object', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'mark_region_visited', arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' }, }); const data = parseToolResult(result) as any; // Echoed in the client-facing shape ({ code, name, ... }), not raw DB columns. expect(data.region).toBeDefined(); expect(data.region.code).toBe('US-CA'); expect(data.region.name).toBe('California'); expect(data.region.country_code).toBe('US'); expect(data.region.manuallyMarked).toBe(true); const row = await findRow(orm, VisitedRegions, { user: user.id, region_code: 'US-CA' }); expect(row).toBeTruthy(); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'mark_region_visited', arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' }, }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // unmark_region_visited // --------------------------------------------------------------------------- describe('Tool: unmark_region_visited', () => { it('removes region and returns success', async () => { const { user } = createUser(testDb); await markRegion(user.id, 'IT-LO', 'Lombardy', 'IT'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-LO' }, }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); expect(await findRow(orm, VisitedRegions, { user: user.id, region_code: 'IT-LO' })).toBeNull(); }); }); it('succeeds even when region was not marked (no-op)', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'XX-YY' }, }); const data = parseToolResult(result) as any; expect(data.success).toBe(true); }); }); }); // --------------------------------------------------------------------------- // get_country_atlas_places // --------------------------------------------------------------------------- describe('Tool: get_country_atlas_places', () => { it('returns empty places array for a new user', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_country_atlas_places', arguments: { countryCode: 'JP' }, }); const data = parseToolResult(result) as any; expect(data.places).toBeDefined(); expect(Array.isArray(data.places)).toBe(true); }); }); it('uppercases the country code like REST does (post-fold quirk fix)', async () => { const { user } = createUser(testDb); await markCountryVisited(orm, user.id, 'JP'); // A trip so countryPlaces reaches the visited_countries lookup pre-quirk-fix too. await makeTrip(orm, user.id, { title: 'Japan' }); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'get_country_atlas_places', arguments: { countryCode: 'jp' }, }); const data = parseToolResult(result) as any; // Legacy passed 'jp' through verbatim and the lookup matched nothing. expect(data.manually_marked).toBe(true); }); }); }); // --------------------------------------------------------------------------- // update_bucket_list_item // --------------------------------------------------------------------------- describe('Tool: update_bucket_list_item', () => { it('updates notes and returns item', async () => { const { user } = createUser(testDb); const itemId = await insertWish(user.id, 'Visit Tokyo'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId, notes: 'Cherry blossom season preferred' }, }); const data = parseToolResult(result) as any; expect(data.item).toBeDefined(); expect(data.item.notes).toBe('Cherry blossom season preferred'); }); }); it('updates name of existing item', async () => { const { user } = createUser(testDb); const itemId = await insertWish(user.id, 'Old Name'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId, name: 'New Name' }, }); const data = parseToolResult(result) as any; expect(data.item.name).toBe('New Name'); }); }); it('returns isError for non-existent item', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId: 99999, notes: 'Will not work' }, }); expect(result.isError).toBe(true); }); }); it('returns isError when the edit would land on another wish (#1898)', async () => { const { user } = createUser(testDb); await insertWish(user.id, 'Japan', '2027-05'); const itemId = await insertWish(user.id, 'Japan', '2028-09'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId, target_date: '2027-05' }, }); expect(result.isError).toBe(true); const row = await findRow(orm, BucketList, { id: itemId }); expect(row?.target_date).toBe('2028-09'); }); }); it('blocks demo user', async () => { process.env.DEMO_MODE = 'true'; const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const itemId = await insertWish(user.id, 'Bucket Item'); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'update_bucket_list_item', arguments: { itemId, notes: 'blocked' }, }); expect(result.isError).toBe(true); }); }); }); // --------------------------------------------------------------------------- // Resource: trek://atlas/stats // --------------------------------------------------------------------------- describe('Resource: trek://atlas/stats', () => { it('returns stats object', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://atlas/stats' }); const data = parseResourceResult(result) as any; expect(data).toBeDefined(); }); }); }); // --------------------------------------------------------------------------- // Resource: trek://atlas/regions // --------------------------------------------------------------------------- describe('Resource: trek://atlas/regions', () => { it('returns the country grouping', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://atlas/regions' }); const data = parseResourceResult(result) as any; expect(data).toEqual({}); }); }); it('returns marked and derived regions together, as the tool does', async () => { const { user } = createUser(testDb); await markRegion(user.id, 'ES-CT', 'Catalonia', 'ES'); const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END }); await cacheRegion(await insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio'); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://atlas/regions' }); const data = parseResourceResult(result) as any; expect(data.ES).toEqual([ { code: 'ES-CT', name: 'Catalonia', placeCount: 0, status: 'visited', manuallyMarked: true }, ]); expect(data.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 1, status: 'visited' }]); }); }); }); // --------------------------------------------------------------------------- // Resource: trek://bucket-list (moved from resources.test.ts with the fold — // that suite's withTools:false harness never attaches registry resources) // --------------------------------------------------------------------------- describe('Resource: trek://bucket-list', () => { it('returns only the current user\'s bucket list items', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); createBucketListItem(testDb, user.id, { name: 'Tokyo' }); createBucketListItem(testDb, other.id, { name: 'Rome' }); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://bucket-list' }); const items = parseResourceResult(result) as any[]; expect(items).toHaveLength(1); expect(items[0].name).toBe('Tokyo'); }); }); it('returns empty array for user with no items', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://bucket-list' }); const items = parseResourceResult(result) as any[]; expect(items).toEqual([]); }); }); }); // --------------------------------------------------------------------------- // Resource: trek://visited-countries (moved from resources.test.ts, same reason) // --------------------------------------------------------------------------- describe('Resource: trek://visited-countries', () => { it('returns only the current user\'s visited countries', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); createVisitedCountry(testDb, user.id, 'FR'); createVisitedCountry(testDb, user.id, 'JP'); createVisitedCountry(testDb, other.id, 'DE'); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://visited-countries' }); const countries = parseResourceResult(result) as any[]; expect(countries).toHaveLength(2); const codes = countries.map((c) => c.country_code); expect(codes).toContain('FR'); expect(codes).toContain('JP'); expect(codes).not.toContain('DE'); }); }); it('returns empty array for user with no visited countries', async () => { const { user } = createUser(testDb); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: 'trek://visited-countries' }); const countries = parseResourceResult(result) as any[]; expect(countries).toEqual([]); }); }); });