feat(mcp): add industrial parks and reports MCP tool servers
Add IndustrialParkServer for KCN/KCX search and analytics, and ReportsServer for market report generation. Include unit tests for industrial parks server. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
273
libs/mcp-servers/src/__tests__/reports.server.test.ts
Normal file
273
libs/mcp-servers/src/__tests__/reports.server.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createReportsServer } from '../reports/reports.server';
|
||||
import type { ReportsDeps } from '../shared/types';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function makeDeps(): ReportsDeps {
|
||||
return { aiServiceBaseUrl: 'http://localhost:8000' };
|
||||
}
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createReportsServer>, name: string) {
|
||||
const tools = (server as unknown as { _registeredTools: Record<string, { handler: (p: unknown) => Promise<ToolResult> }> })._registeredTools;
|
||||
const entry = tools[name];
|
||||
if (!entry) throw new Error(`Tool "${name}" not found`);
|
||||
return entry.handler;
|
||||
}
|
||||
|
||||
function parseResult(result: ToolResult) {
|
||||
return JSON.parse(result.content[0].text) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('ReportsServer', () => {
|
||||
let server: ReturnType<typeof createReportsServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
server = createReportsServer(makeDeps());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates server with correct name', () => {
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generate_report', () => {
|
||||
it('generates a market overview report', async () => {
|
||||
const mockResponse = {
|
||||
report_id: 'rpt-001',
|
||||
report_type: 'market_overview',
|
||||
title: 'Báo cáo thị trường BĐS Quận 7',
|
||||
location: 'Quận 7, Hồ Chí Minh',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Thị trường Q7 tiếp tục tăng trưởng ổn định...',
|
||||
sections: [
|
||||
{
|
||||
title: 'Tổng quan',
|
||||
content: 'Quận 7 ghi nhận 1,200 giao dịch...',
|
||||
charts: [{ type: 'line', title: 'Giá trung bình', data: [] }],
|
||||
},
|
||||
],
|
||||
key_metrics: {
|
||||
avgPriceVND: 4_500_000_000,
|
||||
totalListings: 1200,
|
||||
avgPricePerM2: 55_000_000,
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'market_overview',
|
||||
location: 'Quận 7, Hồ Chí Minh',
|
||||
period: '1y',
|
||||
includeForecasts: false,
|
||||
includeMacro: false,
|
||||
language: 'vi',
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.reportId).toBe('rpt-001');
|
||||
expect(data.reportType).toBe('market_overview');
|
||||
expect(data.title).toContain('Quận 7');
|
||||
expect(data.sections).toHaveLength(1);
|
||||
expect((data.keyMetrics as Record<string, unknown>).totalListings).toBe(1200);
|
||||
});
|
||||
|
||||
it('generates report with forecasts', async () => {
|
||||
const mockResponse = {
|
||||
report_id: 'rpt-002',
|
||||
report_type: 'price_forecast',
|
||||
title: 'Price Forecast - District 2',
|
||||
location: 'Quận 2',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Prices expected to increase 5-8% over next 12 months.',
|
||||
sections: [],
|
||||
key_metrics: {},
|
||||
forecasts: {
|
||||
price_trend: [
|
||||
{ period: 'Q3 2026', predicted_change_pct: 2.5 },
|
||||
{ period: 'Q4 2026', predicted_change_pct: 3.1 },
|
||||
],
|
||||
demand_trend: [
|
||||
{ period: 'Q3 2026', predicted_change_pct: 1.8 },
|
||||
],
|
||||
confidence: 0.82,
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'price_forecast',
|
||||
location: 'Quận 2',
|
||||
includeForecasts: true,
|
||||
includeMacro: false,
|
||||
period: '1y',
|
||||
language: 'en',
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.forecasts).toBeDefined();
|
||||
const forecasts = data.forecasts as Record<string, unknown>;
|
||||
expect(forecasts.confidence).toBe(0.82);
|
||||
expect((forecasts.price_trend as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('sends correct request body to AI service', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
report_id: 'rpt-003',
|
||||
report_type: 'industrial_zone',
|
||||
title: 'Test',
|
||||
location: 'Bình Dương',
|
||||
generated_at: '2026-04-16T10:00:00Z',
|
||||
summary: 'Test',
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
await handler({
|
||||
reportType: 'industrial_zone',
|
||||
location: 'Bình Dương',
|
||||
propertyType: 'industrial',
|
||||
period: '6m',
|
||||
includeForecasts: true,
|
||||
includeMacro: true,
|
||||
language: 'vi',
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/reports/generate',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record<string, unknown>;
|
||||
expect(body.report_type).toBe('industrial_zone');
|
||||
expect(body.location).toBe('Bình Dương');
|
||||
expect(body.property_type).toBe('industrial');
|
||||
expect(body.period).toBe('6m');
|
||||
expect(body.include_forecasts).toBe(true);
|
||||
expect(body.include_macro).toBe(true);
|
||||
expect(body.language).toBe('vi');
|
||||
});
|
||||
|
||||
it('returns error on service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'Service Unavailable',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'generate_report');
|
||||
const result = await handler({
|
||||
reportType: 'market_overview',
|
||||
location: 'Test',
|
||||
period: '1y',
|
||||
includeForecasts: false,
|
||||
includeMacro: false,
|
||||
language: 'vi',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Report generation error (503)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_macro_data', () => {
|
||||
it('retrieves GDP and population data', async () => {
|
||||
const mockResponse = {
|
||||
province: 'Bình Dương',
|
||||
data: {
|
||||
gdp: [
|
||||
{ year: 2023, value: 18.5, unit: 'billion USD', yoy_change: 7.2 },
|
||||
{ year: 2024, value: 20.1, unit: 'billion USD', yoy_change: 8.6 },
|
||||
],
|
||||
population: [
|
||||
{ year: 2023, value: 2800000, unit: 'people', yoy_change: 3.1 },
|
||||
{ year: 2024, value: 2890000, unit: 'people', yoy_change: 3.2 },
|
||||
],
|
||||
},
|
||||
highlights: [
|
||||
'GDP growth exceeds national average',
|
||||
'Strong FDI inflow from electronics sector',
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
const result = await handler({
|
||||
province: 'Bình Dương',
|
||||
categories: ['gdp', 'population'],
|
||||
fromYear: 2023,
|
||||
toYear: 2024,
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.province).toBe('Bình Dương');
|
||||
const macroData = data.data as Record<string, unknown[]>;
|
||||
expect(macroData.gdp).toHaveLength(2);
|
||||
expect(macroData.population).toHaveLength(2);
|
||||
expect((macroData.gdp[0] as Record<string, unknown>).year).toBe(2023);
|
||||
expect((macroData.gdp[0] as Record<string, unknown>).yoyChange).toBe(7.2);
|
||||
expect(data.highlights).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sends correct request body', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }),
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
await handler({
|
||||
province: 'Hồ Chí Minh',
|
||||
categories: ['fdi', 'infrastructure'],
|
||||
fromYear: 2020,
|
||||
toYear: 2025,
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record<string, unknown>;
|
||||
expect(body.province).toBe('Hồ Chí Minh');
|
||||
expect(body.categories).toEqual(['fdi', 'infrastructure']);
|
||||
expect(body.from_year).toBe(2020);
|
||||
expect(body.to_year).toBe(2025);
|
||||
});
|
||||
|
||||
it('returns error on service failure', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Province not found',
|
||||
} as Response);
|
||||
|
||||
const handler = getToolHandler(server, 'get_macro_data');
|
||||
const result = await handler({
|
||||
province: 'Unknown',
|
||||
categories: ['gdp'],
|
||||
fromYear: 2020,
|
||||
toYear: 2025,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Macro data error (404)');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user