feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
504
libs/mcp-servers/src/__tests__/mcp-integration.test.ts
Normal file
504
libs/mcp-servers/src/__tests__/mcp-integration.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user