mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
4f0f80ad69
Fold the 1029-line services/placeService.ts into the DI-native PlacesService: the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list importers become methods over the injected DatabaseService, byte-identical statements, COALESCE semantics and the If-Match conflict protocol (#1135). The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates, the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts (maps.helpers precedent). The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places resource move onto the decorator registry as places.mcp.ts. search_place travels with them because its gate is places:read, not the read-only geo group, and now injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService, so the domain needs no bridge at all. The two assignments.bridge imports that places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place creation, so injecting AssignmentsService there would close DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam reservations.mcp.ts uses, same trade trips.bridge.ts documents. Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes PlacesService methods over the injected DatabaseService/RealtimeService/ MapsService, its pure match selector joins places.helpers.ts — which retires nest/maps/maps.bridge.ts with its last consumer. Adopts places.dto.ts for the seven grandfathered body contracts and clears every PlacesController entry from body-contract-allow-list.ts. Two consequences worth knowing: the bespoke 400s 'Place name is required', 'ids must be an array of numbers' and 'URL is required' are now the ZodValidationPipe envelope, and because a pipe runs before the handler a malformed body 400s ahead of the trip-access 404 it used to follow — the same trade the todo and trips migrations took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's empty-list short-circuit stays reachable. Fixes four defects the relocation had faithfully carried, each with a failing-first regression test: - create dropped lat/lng of exactly 0, so a place on the equator or the prime meridian lost its coordinates (`x || null` cannot tell absent from zero). - duration_minutes: 0 was unsettable — replaced by the 60-minute default on create, and read as "absent" by COALESCE on update. - the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete and the delete_place MCP tool could detach another trip's journey entries for an id they then refused. All three scope first via the new PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had. bulk_delete_places also moves its hook ahead of the DELETE — journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards left the entries as orphans. - the place search treated % and _ as LIKE wildcards; a bare % returned the whole trip. The three clauses now carry ESCAPE with the term escaped. Plus hardening on the Google list fetch: the response is capped at 8 MB (declared content-length and post-read length, the transit.service precedent) and its JSON.parse is guarded, so a malformed provider payload produces the existing 'Invalid list data received from Google Maps' 400 instead of throwing. Quirks deliberately preserved: the non-COALESCE route_color (#776), currency/transport_mode staying unclearable, importGpx returning null for a file that parses but yields no usable geometry, update skipping the If-Match check when the stored updated_at is null, and every string-valued `x || null`. Tests move with their case IDs: placeService.test.ts and the wrapper suite merge into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts, placeEnrichment into places.enrichment.test.ts. The places e2e converts to the DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept and extended so it exercises the decorator path through the test registry, and the plugin-host and booking-import path mocks become constructor stubs.
175 lines
7.6 KiB
TypeScript
175 lines
7.6 KiB
TypeScript
/**
|
|
* Unit tests for MCP resources (resources.ts).
|
|
* Tests the remaining legacy-registrar resources via InMemoryTransport + Client
|
|
* (trek://trips, trek://trips/{tripId} and .../members moved to the
|
|
* DI-discovered TripsMcp — see tools-trips.test.ts;
|
|
* trek://trips/{tripId}/budget, .../budget/per-person and
|
|
* .../budget/settlement moved to the DI-discovered BudgetMcp — see
|
|
* tools-budget.test.ts and tools-budget-advanced.test.ts;
|
|
* trek://categories moved to the DI-discovered CategoriesMcp — see
|
|
* tools-categories.test.ts; trek://trips/{tripId}/todos moved to TodoMcp —
|
|
* see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags
|
|
* moved to PackingMcp — see tools-packing.test.ts;
|
|
* trek://trips/{tripId}/days/{dayId}/notes moved to DayNotesMcp — see
|
|
* tools-notes.test.ts; trek://trips/{tripId}/collab-notes moved to CollabMcp —
|
|
* see tools-notes.test.ts; trek://trips/{tripId}/days and
|
|
* .../accommodations moved to DaysMcp — see tools-days.test.ts and
|
|
* tools-days-accommodations.test.ts).
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: (placeId: number) => {
|
|
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
|
if (!place) return null;
|
|
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
|
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
|
},
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createUser, createTrip, createPlace, addTripMember, createReservation, createCollabNote, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
|
|
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (harness: McpHarness) => Promise<void>) {
|
|
const harness = await createMcpHarness({ userId, withTools: false, withResources: true });
|
|
try {
|
|
await fn(harness);
|
|
} finally {
|
|
await harness.cleanup();
|
|
}
|
|
}
|
|
|
|
// trek://trips and trek://trips/{tripId} moved to tools-trips.test.ts: they
|
|
// now register via the nest-mcp registry inside registerTools (TripsMcp
|
|
// @Resource/@ResourceTemplate), which this file's withTools: false harness
|
|
// never attaches.
|
|
|
|
// The trek://trips/{tripId}/days resource moved to the DI-discovered
|
|
// DaysMcp — see tools-days.test.ts.
|
|
|
|
// The trek://trips/{tripId}/places resource moved to the DI-discovered
|
|
// PlacesMcp — its cases live in tools-places.test.ts (the registry attaches
|
|
// via registerTools, which this withTools:false harness skips).
|
|
|
|
// The trek://trips/{tripId}/budget resource moved to the DI-discovered
|
|
// src/nest/budget/budget.mcp.ts — its cases live in tools-budget.test.ts (the
|
|
// registry attaches via registerTools, which this withTools:false harness skips).
|
|
|
|
// The trek://trips/{tripId}/packing and .../packing/bags resources moved to the
|
|
// DI-discovered PackingMcp — see tools-packing.test.ts.
|
|
|
|
// The trek://trips/{tripId}/reservations resource moved to the DI-discovered
|
|
// ReservationsMcp — see tools-reservations.test.ts.
|
|
|
|
// The trek://trips/{tripId}/days/{dayId}/notes resource moved to the
|
|
// DI-discovered DayNotesMcp — see tools-notes.test.ts.
|
|
|
|
// The trek://trips/{tripId}/accommodations resource moved to the
|
|
// DI-discovered DaysMcp — see tools-days-accommodations.test.ts.
|
|
|
|
// trek://trips/{tripId}/members moved to tools-trips.test.ts (TripsMcp
|
|
// @ResourceTemplate) for the same reason.
|
|
|
|
// trek://trips/{tripId}/collab-notes moved to tools-notes.test.ts: it now
|
|
// registers via the nest-mcp registry inside registerTools (CollabMcp
|
|
// @ResourceTemplate), which this file's withTools: false harness never attaches.
|
|
|
|
// trek://categories moved to tools-categories.test.ts: it now registers via
|
|
// the nest-mcp registry inside registerTools (CategoriesMcp @Resource), which
|
|
// this file's withTools: false harness never attaches.
|
|
|
|
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 withHarness(user.id, async (harness) => {
|
|
const result = await harness.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 withHarness(user.id, async (harness) => {
|
|
const result = await harness.client.readResource({ uri: 'trek://bucket-list' });
|
|
const items = parseResourceResult(result) as any[];
|
|
expect(items).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
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 withHarness(user.id, async (harness) => {
|
|
const result = await harness.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 withHarness(user.id, async (harness) => {
|
|
const result = await harness.client.readResource({ uri: 'trek://visited-countries' });
|
|
const countries = parseResourceResult(result) as any[];
|
|
expect(countries).toEqual([]);
|
|
});
|
|
});
|
|
});
|