/** * Integration test: verifies all MCP servers register correctly in McpRegistryService * and each tool is callable with valid response schemas. * * External HTTP calls (AI service, NestJS API) are mocked via globalThis.fetch. * Typesense is mocked at the client level. */ import type { Client as TypesenseClient } from 'typesense'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { createIndustrialParksServer } from '../industrial-parks/industrial-parks.server'; import { createMarketAnalyticsServer } from '../market-analytics/market-analytics.server'; import { createPropertySearchServer } from '../property-search/property-search.server'; import { createReportsServer } from '../reports/reports.server'; import { createValuationServer } from '../valuation/valuation.server'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type ToolResult = { content: { type: string; text: string }[]; isError?: boolean; }; // --------------------------------------------------------------------------- // Mocks — Typesense client // --------------------------------------------------------------------------- function createMockTypesenseClient(defaultHits: unknown[] = []) { const search = vi.fn().mockResolvedValue({ hits: defaultHits.map((d) => ({ document: d })), found: defaultHits.length, search_time_ms: 2, }); return { collections: vi.fn().mockReturnValue({ documents: vi.fn().mockReturnValue({ search }), }), _search: search, }; } // --------------------------------------------------------------------------- // Mocks — fetch responses for each backend // --------------------------------------------------------------------------- const MOCK_RESPONSES: Record = { '/industrial/analyze-location': { overall_score: 8.2, connectivity: { nearest_port: { name: 'Cảng Cát Lái', distanceKm: 22 }, nearest_airport: { name: 'Tân Sơn Nhất', distanceKm: 28 }, nearest_highway: { name: 'QL1A', distanceKm: 1.5 }, }, infrastructure: { power_availability: '110kV on-site', water_supply: 'Municipal', wastewater_treatment: 'Central WWTP', telecom: 'Fiber optic', }, labor_market: { worker_pool_radius_30km: 450000, average_wage_usd: 290, nearby_universities: ['ĐH Bình Dương'], }, incentives: ['CIT exemption 4 years'], risks: ['Flooding risk'], }, '/industrial/estimate-rent': { estimated_rent_usd_m2: 4.5, pricing_unit: 'USD/m²/month', total_monthly_usd: 45000, total_lease_usd: 5400000, management_fee_usd_m2: 0.6, deposit_months: 3, market_comparison: { province_low: 3.0, province_high: 7.0, province_avg: 4.8, }, breakdown: [ { item: 'Base rent', amount: 38000 }, { item: 'Management fee', amount: 6000 }, ], }, '/reports/generate': { report_id: 'rpt-int-001', report_type: 'market_overview', title: 'Báo cáo thị trường Q7', location: 'Quận 7, Hồ Chí Minh', generated_at: '2026-04-16T10:00:00Z', summary: 'Thị trường ổn định', sections: [{ title: 'Tổng quan', content: '...', charts: [] }], key_metrics: { avgPriceVND: 4_500_000_000 }, }, '/reports/macro-data': { province: 'Bình Dương', data: { gdp: [{ year: 2024, value: 20.1, unit: 'billion USD', yoy_change: 8.6 }], }, highlights: ['GDP above national average'], }, }; function mockFetchForUrl(url: string): Response { for (const [path, body] of Object.entries(MOCK_RESPONSES)) { if (url.includes(path)) { return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body), } as unknown as Response; } } return { ok: false, status: 404, text: async () => 'Not found', } as unknown as Response; } // --------------------------------------------------------------------------- // Industrial park sample document (for Typesense search results) // --------------------------------------------------------------------------- const SAMPLE_PARK = { parkId: 'park-int-001', name: 'KCN VSIP II-A', nameEn: 'VSIP II-A Industrial Park', developer: 'VSIP Group', province: 'Bình Dương', region: 'south', status: 'operational', totalAreaHa: 345, remainingAreaHa: 62, occupancyRate: 82, landRentUsdM2Year: 90, rbfRentUsdM2Month: 4.8, rbwRentUsdM2Month: 3.5, targetIndustries: ['electronics', 'automotive'], tenantCount: 85, }; // --------------------------------------------------------------------------- // Helper: extract tool handler from McpServer internal state // --------------------------------------------------------------------------- function getToolHandler( server: unknown, name: string, ): (params: unknown) => Promise { const tools = ( server as { _registeredTools: Record Promise }> } )._registeredTools; const entry = tools[name]; if (!entry) { throw new Error(`Tool "${name}" not registered. Available: ${Object.keys(tools).join(', ')}`); } return entry.handler; } function parseToolResult(result: ToolResult): Record { expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('text'); return JSON.parse(result.content[0].text) as Record; } // --------------------------------------------------------------------------- // Integration tests // --------------------------------------------------------------------------- describe('MCP Integration: all servers and tools end-to-end', () => { const typesenseClient = createMockTypesenseClient([SAMPLE_PARK]); let industrialServer: ReturnType; let reportsServer: ReturnType; const fetchSpy = vi.spyOn(globalThis, 'fetch'); beforeAll(() => { fetchSpy.mockImplementation(async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; return mockFetchForUrl(url); }); industrialServer = createIndustrialParksServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'industrial_parks', aiServiceBaseUrl: 'http://ai-service:8000', }); reportsServer = createReportsServer({ apiBaseUrl: 'http://api:3001/api/v1', }); }); afterAll(() => { fetchSpy.mockRestore(); }); // ----------------------------------------------------------------------- // 1. Server factory tests — all 5 factories produce valid McpServer instances // ----------------------------------------------------------------------- describe('server factories', () => { it('creates all 5 server instances without errors', () => { expect(industrialServer).toBeDefined(); expect(reportsServer).toBeDefined(); const propertySearch = createPropertySearchServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'listings', }); expect(propertySearch).toBeDefined(); const marketAnalytics = createMarketAnalyticsServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'listings', }); expect(marketAnalytics).toBeDefined(); const valuation = createValuationServer({ aiServiceBaseUrl: 'http://ai-service:8000', }); expect(valuation).toBeDefined(); }); }); // ----------------------------------------------------------------------- // 2. Industrial parks server — 3 tools // ----------------------------------------------------------------------- describe('industrial-parks server', () => { it('search_industrial_parks: returns structured results from Typesense', async () => { const handler = getToolHandler(industrialServer, 'search_industrial_parks'); const result = await handler({ query: 'VSIP Bình Dương', page: 1, perPage: 20, }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result); // Schema validation expect(data).toHaveProperty('totalFound'); expect(data).toHaveProperty('page'); expect(data).toHaveProperty('perPage'); expect(data).toHaveProperty('searchTimeMs'); expect(data).toHaveProperty('results'); expect(typeof data.totalFound).toBe('number'); const results = data.results as Record[]; expect(results.length).toBeGreaterThan(0); // Validate result item schema const item = results[0]; expect(item).toHaveProperty('parkId'); expect(item).toHaveProperty('name'); expect(item).toHaveProperty('developer'); expect(item).toHaveProperty('province'); expect(item).toHaveProperty('region'); expect(item).toHaveProperty('status'); expect(item).toHaveProperty('totalAreaHa'); expect(item).toHaveProperty('remainingAreaHa'); expect(item).toHaveProperty('occupancyRate'); expect(item).toHaveProperty('landRentUsdM2Year'); expect(item).toHaveProperty('targetIndustries'); expect(item).toHaveProperty('tenantCount'); }); it('analyze_industrial_location: calls AI service and returns analysis schema', async () => { const handler = getToolHandler(industrialServer, 'analyze_industrial_location'); const result = await handler({ latitude: 11.05, longitude: 106.65, targetIndustry: 'electronics', }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result); // Schema validation expect(data).toHaveProperty('overallScore'); expect(data).toHaveProperty('connectivity'); expect(data).toHaveProperty('infrastructure'); expect(data).toHaveProperty('laborMarket'); expect(data).toHaveProperty('incentives'); expect(data).toHaveProperty('risks'); expect(typeof data.overallScore).toBe('number'); const connectivity = data.connectivity as Record; expect(connectivity).toHaveProperty('nearestPort'); expect(connectivity).toHaveProperty('nearestAirport'); // Verify correct URL was called expect(fetchSpy).toHaveBeenCalledWith( 'http://ai-service:8000/industrial/analyze-location', expect.objectContaining({ method: 'POST' }), ); }); it('estimate_industrial_rent: calls AI service and returns rent estimate schema', async () => { const handler = getToolHandler(industrialServer, 'estimate_industrial_rent'); const result = await handler({ province: 'Bình Dương', propertyType: 'ready_built_factory', areaM2: 10000, leaseDurationYears: 10, }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result); // Schema validation expect(data).toHaveProperty('estimatedRentUsdM2'); expect(data).toHaveProperty('pricingUnit'); expect(data).toHaveProperty('totalMonthlyUsd'); expect(data).toHaveProperty('totalLeaseUsd'); expect(data).toHaveProperty('managementFeeUsdM2'); expect(data).toHaveProperty('depositMonths'); expect(data).toHaveProperty('marketComparison'); expect(data).toHaveProperty('breakdown'); expect(data).toHaveProperty('input'); expect(typeof data.estimatedRentUsdM2).toBe('number'); const mc = data.marketComparison as Record; expect(mc).toHaveProperty('provinceLow'); expect(mc).toHaveProperty('provinceHigh'); expect(mc).toHaveProperty('provinceAvg'); // Verify correct URL was called expect(fetchSpy).toHaveBeenCalledWith( 'http://ai-service:8000/industrial/estimate-rent', expect.objectContaining({ method: 'POST' }), ); }); }); // ----------------------------------------------------------------------- // 3. Reports server — 2 tools // ----------------------------------------------------------------------- describe('reports server', () => { it('generate_report: calls NestJS API and returns report schema', async () => { const handler = getToolHandler(reportsServer, 'generate_report'); const result = await handler({ reportType: 'market_overview', location: 'Quận 7, Hồ Chí Minh', period: '1y', includeForecasts: false, includeMacro: false, language: 'vi', }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result); // Schema validation expect(data).toHaveProperty('reportId'); expect(data).toHaveProperty('reportType'); expect(data).toHaveProperty('title'); expect(data).toHaveProperty('location'); expect(data).toHaveProperty('generatedAt'); expect(data).toHaveProperty('summary'); expect(data).toHaveProperty('sections'); expect(data).toHaveProperty('keyMetrics'); expect(typeof data.reportId).toBe('string'); expect(Array.isArray(data.sections)).toBe(true); // Verify correct URL was called (NestJS API, not AI service) expect(fetchSpy).toHaveBeenCalledWith( 'http://api:3001/api/v1/reports/generate', expect.objectContaining({ method: 'POST' }), ); }); it('get_macro_data: calls NestJS API with GET and returns macro data schema', async () => { const handler = getToolHandler(reportsServer, 'get_macro_data'); const result = await handler({ province: 'Bình Dương', categories: ['gdp'], fromYear: 2024, toYear: 2024, }); expect(result.isError).toBeFalsy(); const data = parseToolResult(result); // Schema validation expect(data).toHaveProperty('province'); expect(data).toHaveProperty('period'); expect(data).toHaveProperty('data'); expect(data).toHaveProperty('highlights'); expect(data.province).toBe('Bình Dương'); const period = data.period as Record; expect(period.from).toBe(2024); expect(period.to).toBe(2024); const macroData = data.data as Record; expect(macroData).toHaveProperty('gdp'); expect(macroData.gdp).toHaveLength(1); const gdpPoint = macroData.gdp[0] as Record; expect(gdpPoint).toHaveProperty('year'); expect(gdpPoint).toHaveProperty('value'); expect(gdpPoint).toHaveProperty('unit'); expect(gdpPoint).toHaveProperty('yoyChange'); // Verify it used GET (not POST) const macroCall = fetchSpy.mock.calls.find( (call) => (call[0] as string).includes('/reports/macro-data'), ); expect(macroCall).toBeDefined(); expect((macroCall![1] as RequestInit).method).toBe('GET'); }); }); // ----------------------------------------------------------------------- // 4. Env var routing: industrial tools → AI_SERVICE_URL, reports → API_BASE_URL // ----------------------------------------------------------------------- describe('env var routing', () => { it('industrial tools call aiServiceBaseUrl (AI_SERVICE_URL)', async () => { const analyzeCall = fetchSpy.mock.calls.find( (call) => (call[0] as string).includes('/industrial/analyze-location'), ); expect(analyzeCall).toBeDefined(); expect((analyzeCall![0] as string).startsWith('http://ai-service:8000')).toBe(true); const rentCall = fetchSpy.mock.calls.find( (call) => (call[0] as string).includes('/industrial/estimate-rent'), ); expect(rentCall).toBeDefined(); expect((rentCall![0] as string).startsWith('http://ai-service:8000')).toBe(true); }); it('report tools call apiBaseUrl (API_BASE_URL)', async () => { const reportCall = fetchSpy.mock.calls.find( (call) => (call[0] as string).includes('/reports/generate'), ); expect(reportCall).toBeDefined(); expect((reportCall![0] as string).startsWith('http://api:3001')).toBe(true); const macroCall = fetchSpy.mock.calls.find( (call) => (call[0] as string).includes('/reports/macro-data'), ); expect(macroCall).toBeDefined(); expect((macroCall![0] as string).startsWith('http://api:3001')).toBe(true); }); }); // ----------------------------------------------------------------------- // 5. Registry simulation — verify all servers can be registered // ----------------------------------------------------------------------- describe('registry integration', () => { it('McpRegistryService registers industrial-parks and reports servers', async () => { // Simulate what McpRegistryService.onModuleInit does const servers = new Map(); servers.set( 'property-search', createPropertySearchServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'listings', }), ); servers.set( 'market-analytics', createMarketAnalyticsServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'listings', }), ); servers.set( 'valuation', createValuationServer({ aiServiceBaseUrl: 'http://ai-service:8000' }), ); servers.set( 'industrial-parks', createIndustrialParksServer({ typesenseClient: typesenseClient as unknown as TypesenseClient, collectionName: 'industrial_parks', aiServiceBaseUrl: 'http://ai-service:8000', }), ); servers.set( 'reports', createReportsServer({ apiBaseUrl: 'http://api:3001/api/v1' }), ); // All 5 servers should be registered expect(servers.size).toBe(5); expect(Array.from(servers.keys()).sort()).toEqual([ 'industrial-parks', 'market-analytics', 'property-search', 'reports', 'valuation', ]); // Each server should be a valid McpServer instance for (const [name, server] of servers) { expect(server, `Server "${name}" should be defined`).toBeDefined(); } }); }); });