505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {
|
|
'/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<ToolResult> {
|
|
const tools = (
|
|
server as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> }
|
|
)._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<string, unknown> {
|
|
expect(result.content).toHaveLength(1);
|
|
expect(result.content[0].type).toBe('text');
|
|
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests
|
|
// ---------------------------------------------------------------------------
|
|
describe('MCP Integration: all servers and tools end-to-end', () => {
|
|
const typesenseClient = createMockTypesenseClient([SAMPLE_PARK]);
|
|
|
|
let industrialServer: ReturnType<typeof createIndustrialParksServer>;
|
|
let reportsServer: ReturnType<typeof createReportsServer>;
|
|
|
|
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<string, unknown>[];
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, number>;
|
|
expect(period.from).toBe(2024);
|
|
expect(period.to).toBe(2024);
|
|
|
|
const macroData = data.data as Record<string, unknown[]>;
|
|
expect(macroData).toHaveProperty('gdp');
|
|
expect(macroData.gdp).toHaveLength(1);
|
|
|
|
const gdpPoint = macroData.gdp[0] as Record<string, unknown>;
|
|
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<string, unknown>();
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
});
|