feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View 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();
}
});
});
});