test(mcp-servers): add unit tests for property search, market analytics, and valuation servers
29 tests covering all 9 MCP tools: search_properties, compare_properties, get_property_details, market_report, price_trends, district_comparison, estimate_property_value, extract_listing_features, and batch_valuation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
221
libs/mcp-servers/src/__tests__/market-analytics.server.test.ts
Normal file
221
libs/mcp-servers/src/__tests__/market-analytics.server.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createMarketAnalyticsServer } from '../market-analytics/market-analytics.server';
|
||||
import type { MarketAnalyticsDeps } from '../shared/types';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function createMockClient(searchResult: unknown = { hits: [], found: 0 }) {
|
||||
const search = vi.fn().mockResolvedValue(searchResult);
|
||||
return {
|
||||
collections: vi.fn().mockReturnValue({
|
||||
documents: vi.fn().mockReturnValue({ search }),
|
||||
}),
|
||||
_search: search,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(client: ReturnType<typeof createMockClient>): MarketAnalyticsDeps {
|
||||
return {
|
||||
typesenseClient: client as unknown as MarketAnalyticsDeps['typesenseClient'],
|
||||
collectionName: 'test-listings',
|
||||
};
|
||||
}
|
||||
|
||||
function makeHits(docs: Record<string, unknown>[]) {
|
||||
return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 5 };
|
||||
}
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createMarketAnalyticsServer>, 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>;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneMonthAgo = now - 30 * 24 * 3600;
|
||||
const twoMonthsAgo = now - 60 * 24 * 3600;
|
||||
|
||||
const makeListingDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
listingId: 'lst-001',
|
||||
title: 'Căn hộ',
|
||||
propertyType: 'apartment',
|
||||
transactionType: 'sale',
|
||||
priceVND: 3_000_000_000,
|
||||
pricePerM2: 42_857_143,
|
||||
areaM2: 70,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
publishedAt: now,
|
||||
status: 'active',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('MarketAnalyticsServer', () => {
|
||||
let client: ReturnType<typeof createMockClient>;
|
||||
let server: ReturnType<typeof createMarketAnalyticsServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createMockClient();
|
||||
server = createMarketAnalyticsServer(makeDeps(client));
|
||||
});
|
||||
|
||||
describe('market_report', () => {
|
||||
it('generates report with price stats and distribution', async () => {
|
||||
const docs = [
|
||||
makeListingDoc({ priceVND: 2_000_000_000, areaM2: 50, pricePerM2: 40_000_000 }),
|
||||
makeListingDoc({ priceVND: 3_000_000_000, areaM2: 70, pricePerM2: 42_857_143 }),
|
||||
makeListingDoc({ priceVND: 5_000_000_000, areaM2: 100, pricePerM2: 50_000_000 }),
|
||||
];
|
||||
client._search.mockResolvedValue(makeHits(docs));
|
||||
|
||||
const handler = getToolHandler(server, 'market_report');
|
||||
const result = await handler({
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.district).toBe('Quận 7');
|
||||
expect(data.city).toBe('Hồ Chí Minh');
|
||||
expect(data.totalListings).toBe(3);
|
||||
expect(data.medianPriceVND).toBe(3_000_000_000);
|
||||
expect(data.priceDistribution).toBeDefined();
|
||||
expect(Array.isArray(data.priceDistribution)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies optional filters', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'market_report');
|
||||
|
||||
await handler({
|
||||
district: 'Quận 2',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'villa',
|
||||
transactionType: 'sale',
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('district:=Quận 2');
|
||||
expect(filterBy).toContain('propertyType:=villa');
|
||||
expect(filterBy).toContain('transactionType:=sale');
|
||||
});
|
||||
|
||||
it('returns message when no listings found', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'market_report');
|
||||
const result = await handler({ district: 'Quận 99', city: 'Hồ Chí Minh' });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.totalListings).toBe(0);
|
||||
expect(data.message).toContain('No listings found');
|
||||
});
|
||||
|
||||
it('calculates median correctly for even count', async () => {
|
||||
const docs = [
|
||||
makeListingDoc({ priceVND: 2_000_000_000 }),
|
||||
makeListingDoc({ priceVND: 4_000_000_000 }),
|
||||
];
|
||||
client._search.mockResolvedValue(makeHits(docs));
|
||||
|
||||
const handler = getToolHandler(server, 'market_report');
|
||||
const result = await handler({ district: 'Quận 7', city: 'Hồ Chí Minh' });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.medianPriceVND).toBe(3_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('price_trends', () => {
|
||||
it('groups listings by month', async () => {
|
||||
const docs = [
|
||||
makeListingDoc({ priceVND: 3_000_000_000, publishedAt: oneMonthAgo }),
|
||||
makeListingDoc({ priceVND: 3_200_000_000, publishedAt: oneMonthAgo + 86400 }),
|
||||
makeListingDoc({ priceVND: 3_500_000_000, publishedAt: twoMonthsAgo }),
|
||||
];
|
||||
client._search.mockResolvedValue(makeHits(docs));
|
||||
|
||||
const handler = getToolHandler(server, 'price_trends');
|
||||
const result = await handler({ city: 'Hồ Chí Minh', months: 6 });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.city).toBe('Hồ Chí Minh');
|
||||
expect(data.periodMonths).toBe(6);
|
||||
expect(Array.isArray(data.dataPoints)).toBe(true);
|
||||
expect((data.dataPoints as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('applies time cutoff filter', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'price_trends');
|
||||
|
||||
await handler({ city: 'Hồ Chí Minh', months: 3 });
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toMatch(/publishedAt:>=\d+/);
|
||||
});
|
||||
|
||||
it('applies district filter when provided', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'price_trends');
|
||||
|
||||
await handler({ district: 'Quận 7', city: 'Hồ Chí Minh', months: 6 });
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('district:=Quận 7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('district_comparison', () => {
|
||||
it('compares multiple districts', async () => {
|
||||
const q7Docs = [
|
||||
makeListingDoc({ district: 'Quận 7', priceVND: 3_000_000_000, areaM2: 70 }),
|
||||
makeListingDoc({ district: 'Quận 7', priceVND: 4_000_000_000, areaM2: 80 }),
|
||||
];
|
||||
const q2Docs = [
|
||||
makeListingDoc({ district: 'Quận 2', priceVND: 5_000_000_000, areaM2: 100 }),
|
||||
];
|
||||
|
||||
client._search
|
||||
.mockResolvedValueOnce(makeHits(q7Docs))
|
||||
.mockResolvedValueOnce(makeHits(q2Docs));
|
||||
|
||||
const handler = getToolHandler(server, 'district_comparison');
|
||||
const result = await handler({
|
||||
city: 'Hồ Chí Minh',
|
||||
districts: ['Quận 7', 'Quận 2'],
|
||||
});
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.city).toBe('Hồ Chí Minh');
|
||||
const districts = data.districts as { district: string; totalListings: number; avgPriceVND: number }[];
|
||||
expect(districts).toHaveLength(2);
|
||||
expect(districts[0].district).toBe('Quận 7');
|
||||
expect(districts[0].totalListings).toBe(2);
|
||||
expect(districts[0].avgPriceVND).toBe(3_500_000_000);
|
||||
expect(districts[1].district).toBe('Quận 2');
|
||||
expect(districts[1].totalListings).toBe(1);
|
||||
});
|
||||
|
||||
it('handles districts with no listings', async () => {
|
||||
client._search
|
||||
.mockResolvedValueOnce(makeHits([]))
|
||||
.mockResolvedValueOnce(makeHits([]));
|
||||
|
||||
const handler = getToolHandler(server, 'district_comparison');
|
||||
const result = await handler({
|
||||
city: 'Hồ Chí Minh',
|
||||
districts: ['Empty1', 'Empty2'],
|
||||
});
|
||||
const data = parseResult(result);
|
||||
const districts = data.districts as { avgPriceVND: number | null }[];
|
||||
|
||||
expect(districts[0].avgPriceVND).toBeNull();
|
||||
expect(districts[1].avgPriceVND).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
233
libs/mcp-servers/src/__tests__/property-search.server.test.ts
Normal file
233
libs/mcp-servers/src/__tests__/property-search.server.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createPropertySearchServer } from '../property-search/property-search.server';
|
||||
import type { PropertySearchDeps } from '../shared/types';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function createMockClient(searchResult: unknown = { hits: [], found: 0 }) {
|
||||
const search = vi.fn().mockResolvedValue(searchResult);
|
||||
return {
|
||||
collections: vi.fn().mockReturnValue({
|
||||
documents: vi.fn().mockReturnValue({ search }),
|
||||
}),
|
||||
_search: search,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(client: ReturnType<typeof createMockClient>): PropertySearchDeps {
|
||||
return {
|
||||
typesenseClient: client as unknown as PropertySearchDeps['typesenseClient'],
|
||||
collectionName: 'test-listings',
|
||||
};
|
||||
}
|
||||
|
||||
function makeHits(docs: Record<string, unknown>[]) {
|
||||
return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 5 };
|
||||
}
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createPropertySearchServer>, 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>;
|
||||
}
|
||||
|
||||
const sampleDoc = {
|
||||
listingId: 'lst-001',
|
||||
title: 'Căn hộ 2PN Quận 7',
|
||||
propertyType: 'apartment',
|
||||
transactionType: 'sale',
|
||||
priceVND: 3_500_000_000,
|
||||
pricePerM2: 50_000_000,
|
||||
areaM2: 70,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
address: '123 Nguyễn Hữu Thọ',
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
};
|
||||
|
||||
describe('PropertySearchServer', () => {
|
||||
let client: ReturnType<typeof createMockClient>;
|
||||
let server: ReturnType<typeof createPropertySearchServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createMockClient(makeHits([sampleDoc]));
|
||||
server = createPropertySearchServer(makeDeps(client));
|
||||
});
|
||||
|
||||
it('creates server with correct name', () => {
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
describe('search_properties', () => {
|
||||
it('searches with basic query', async () => {
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
const result = await handler({ query: 'căn hộ Quận 7', page: 1, perPage: 20 });
|
||||
|
||||
expect(client.collections).toHaveBeenCalledWith('test-listings');
|
||||
expect(client._search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
q: 'căn hộ Quận 7',
|
||||
query_by: 'title,description,address,district,city,projectName',
|
||||
}),
|
||||
);
|
||||
|
||||
const data = parseResult(result);
|
||||
expect(data.totalFound).toBe(1);
|
||||
expect(data.results).toHaveLength(1);
|
||||
expect((data.results as Record<string, unknown>[])[0].listingId).toBe('lst-001');
|
||||
});
|
||||
|
||||
it('applies all filters correctly', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
propertyType: 'apartment',
|
||||
transactionType: 'sale',
|
||||
minPrice: 1_000_000_000,
|
||||
maxPrice: 5_000_000_000,
|
||||
bedrooms: 2,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('status:=active');
|
||||
expect(filterBy).toContain('propertyType:=apartment');
|
||||
expect(filterBy).toContain('transactionType:=sale');
|
||||
expect(filterBy).toContain('priceVND:>=1000000000');
|
||||
expect(filterBy).toContain('priceVND:<=5000000000');
|
||||
expect(filterBy).toContain('bedrooms:=2');
|
||||
expect(filterBy).toContain('district:=Quận 7');
|
||||
expect(filterBy).toContain('city:=Hồ Chí Minh');
|
||||
});
|
||||
|
||||
it('applies geo filter when coordinates provided', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
latitude: 10.7297,
|
||||
longitude: 106.7253,
|
||||
radiusKm: 5,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
const filterBy = client._search.mock.calls[0][0].filter_by as string;
|
||||
expect(filterBy).toContain('location:(10.7297, 106.7253, 5 km)');
|
||||
});
|
||||
|
||||
it('sorts by price ascending', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({ query: '*', sortBy: 'price_asc', page: 1, perPage: 20 });
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('priceVND:asc');
|
||||
});
|
||||
|
||||
it('sorts by price descending', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({ query: '*', sortBy: 'price_desc', page: 1, perPage: 20 });
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('priceVND:desc');
|
||||
});
|
||||
|
||||
it('sorts by distance when coordinates present', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({
|
||||
query: '*',
|
||||
sortBy: 'distance',
|
||||
latitude: 10.73,
|
||||
longitude: 106.73,
|
||||
radiusKm: 3,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('location(10.73, 106.73):asc');
|
||||
});
|
||||
|
||||
it('sorts by relevance when query present', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
|
||||
await handler({ query: 'villa', sortBy: 'relevance', page: 1, perPage: 20 });
|
||||
expect(client._search.mock.calls[0][0].sort_by).toBe('_text_match:desc,publishedAt:desc');
|
||||
});
|
||||
|
||||
it('returns empty results gracefully', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'search_properties');
|
||||
const result = await handler({ query: 'nonexistent', page: 1, perPage: 20 });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.totalFound).toBe(0);
|
||||
expect(data.results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare_properties', () => {
|
||||
it('compares multiple properties with stats', async () => {
|
||||
const docs = [
|
||||
{ ...sampleDoc, listingId: 'lst-001', priceVND: 3_000_000_000, areaM2: 60, pricePerM2: 50_000_000 },
|
||||
{ ...sampleDoc, listingId: 'lst-002', priceVND: 5_000_000_000, areaM2: 90, pricePerM2: 55_555_556 },
|
||||
];
|
||||
client._search.mockResolvedValue(makeHits(docs));
|
||||
|
||||
const handler = getToolHandler(server, 'compare_properties');
|
||||
const result = await handler({ listingIds: ['lst-001', 'lst-002'] });
|
||||
const data = parseResult(result) as Record<string, unknown>;
|
||||
|
||||
expect(data.properties).toHaveLength(2);
|
||||
const comparison = data.comparison as Record<string, Record<string, number>>;
|
||||
expect(comparison.priceRange.min).toBe(3_000_000_000);
|
||||
expect(comparison.priceRange.max).toBe(5_000_000_000);
|
||||
expect(comparison.areaRange.min).toBe(60);
|
||||
expect(comparison.areaRange.max).toBe(90);
|
||||
});
|
||||
|
||||
it('returns error when fewer than 2 listings found', async () => {
|
||||
client._search.mockResolvedValue(makeHits([sampleDoc]));
|
||||
const handler = getToolHandler(server, 'compare_properties');
|
||||
const result = await handler({ listingIds: ['lst-001', 'lst-999'] });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Need at least 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_property_details', () => {
|
||||
it('returns full listing document', async () => {
|
||||
const handler = getToolHandler(server, 'get_property_details');
|
||||
const result = await handler({ listingId: 'lst-001' });
|
||||
const data = parseResult(result);
|
||||
|
||||
expect(data.listingId).toBe('lst-001');
|
||||
expect(data.title).toBe('Căn hộ 2PN Quận 7');
|
||||
});
|
||||
|
||||
it('returns error when listing not found', async () => {
|
||||
client._search.mockResolvedValue(makeHits([]));
|
||||
const handler = getToolHandler(server, 'get_property_details');
|
||||
const result = await handler({ listingId: 'nonexistent' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(parseResult(result).error).toBe('Listing not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
260
libs/mcp-servers/src/__tests__/valuation.server.test.ts
Normal file
260
libs/mcp-servers/src/__tests__/valuation.server.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createValuationServer } from '../valuation/valuation.server';
|
||||
|
||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||
|
||||
function getToolHandler(server: ReturnType<typeof createValuationServer>, 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>;
|
||||
}
|
||||
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
|
||||
const avmResponse = {
|
||||
estimated_price_vnd: 3_500_000_000,
|
||||
confidence: 0.85,
|
||||
price_per_m2: 50_000_000,
|
||||
price_range_low: 3_000_000_000,
|
||||
price_range_high: 4_000_000_000,
|
||||
};
|
||||
|
||||
const featureResponse = {
|
||||
features: {
|
||||
area: 75,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
property_type: 'apartment',
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
frontage: null,
|
||||
road_width: null,
|
||||
price_mentioned: 3_500_000_000,
|
||||
has_legal_paper: true,
|
||||
},
|
||||
tokens: ['căn', 'hộ', '2', 'phòng', 'ngủ'],
|
||||
entities: [{ text: 'Quận 7', label: 'LOCATION' }],
|
||||
};
|
||||
|
||||
describe('ValuationServer', () => {
|
||||
let server: ReturnType<typeof createValuationServer>;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
server = createValuationServer({ aiServiceBaseUrl: BASE_URL });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('estimate_property_value', () => {
|
||||
it('returns valuation estimate on success', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => avmResponse,
|
||||
});
|
||||
|
||||
const handler = getToolHandler(server, 'estimate_property_value');
|
||||
const result = await handler({
|
||||
area: 70,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'apartment',
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: 0,
|
||||
frontage: 0,
|
||||
roadWidth: 0,
|
||||
hasLegalPaper: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/avm/predict`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const data = parseResult(result);
|
||||
expect(data.estimatedPriceVND).toBe(3_500_000_000);
|
||||
expect(data.confidence).toBe(0.85);
|
||||
expect(data.pricePerM2).toBe(50_000_000);
|
||||
expect(data.priceRangeLow).toBe(3_000_000_000);
|
||||
expect(data.priceRangeHigh).toBe(4_000_000_000);
|
||||
});
|
||||
|
||||
it('sends correct body to AVM service', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => avmResponse });
|
||||
|
||||
const handler = getToolHandler(server, 'estimate_property_value');
|
||||
await handler({
|
||||
area: 100,
|
||||
district: 'Quận 2',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'villa',
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
floors: 2,
|
||||
frontage: 6,
|
||||
roadWidth: 12,
|
||||
yearBuilt: 2020,
|
||||
hasLegalPaper: true,
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body) as Record<string, unknown>;
|
||||
expect(body.area).toBe(100);
|
||||
expect(body.district).toBe('Quận 2');
|
||||
expect(body.property_type).toBe('villa');
|
||||
expect(body.bedrooms).toBe(4);
|
||||
expect(body.year_built).toBe(2020);
|
||||
expect(body.has_legal_paper).toBe(true);
|
||||
expect(body.road_width).toBe(12);
|
||||
});
|
||||
|
||||
it('returns error when AVM service fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
});
|
||||
|
||||
const handler = getToolHandler(server, 'estimate_property_value');
|
||||
const result = await handler({
|
||||
area: 70,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'apartment',
|
||||
bedrooms: 0,
|
||||
bathrooms: 0,
|
||||
floors: 0,
|
||||
frontage: 0,
|
||||
roadWidth: 0,
|
||||
hasLegalPaper: true,
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('AVM service error (500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract_listing_features', () => {
|
||||
it('extracts features from Vietnamese text', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => featureResponse,
|
||||
});
|
||||
|
||||
const handler = getToolHandler(server, 'extract_listing_features');
|
||||
const result = await handler({
|
||||
text: 'Bán căn hộ 2 phòng ngủ 75m2 Quận 7, sổ hồng, giá 3.5 tỷ',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/avm/extract-features`,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
|
||||
const data = parseResult(result);
|
||||
const features = data.features as Record<string, unknown>;
|
||||
expect(features.area).toBe(75);
|
||||
expect(features.district).toBe('Quận 7');
|
||||
expect(features.bedrooms).toBe(2);
|
||||
expect(features.hasLegalPaper).toBe(true);
|
||||
expect((data.entities as unknown[]).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns error when extraction fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
text: async () => 'Unprocessable Entity',
|
||||
});
|
||||
|
||||
const handler = getToolHandler(server, 'extract_listing_features');
|
||||
const result = await handler({ text: '' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const data = parseResult(result);
|
||||
expect(data.error).toContain('Feature extraction error (422)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch_valuation', () => {
|
||||
it('valuates multiple properties', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => avmResponse,
|
||||
});
|
||||
|
||||
const handler = getToolHandler(server, 'batch_valuation');
|
||||
const result = await handler({
|
||||
properties: [
|
||||
{
|
||||
area: 70,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'apartment',
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: 0,
|
||||
frontage: 0,
|
||||
roadWidth: 0,
|
||||
hasLegalPaper: true,
|
||||
},
|
||||
{
|
||||
area: 100,
|
||||
district: 'Quận 2',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'villa',
|
||||
bedrooms: 4,
|
||||
bathrooms: 3,
|
||||
floors: 2,
|
||||
frontage: 5,
|
||||
roadWidth: 10,
|
||||
hasLegalPaper: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const data = parseResult(result);
|
||||
const valuations = data.valuations as { index: number; valuation: Record<string, unknown> }[];
|
||||
expect(valuations).toHaveLength(2);
|
||||
expect(valuations[0].index).toBe(0);
|
||||
expect(valuations[0].valuation.estimatedPriceVND).toBe(3_500_000_000);
|
||||
expect(valuations[1].index).toBe(1);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => avmResponse })
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'Error' });
|
||||
|
||||
const handler = getToolHandler(server, 'batch_valuation');
|
||||
const result = await handler({
|
||||
properties: [
|
||||
{ area: 70, district: 'Q7', city: 'HCM', propertyType: 'apartment', bedrooms: 0, bathrooms: 0, floors: 0, frontage: 0, roadWidth: 0, hasLegalPaper: true },
|
||||
{ area: 100, district: 'Q2', city: 'HCM', propertyType: 'villa', bedrooms: 0, bathrooms: 0, floors: 0, frontage: 0, roadWidth: 0, hasLegalPaper: true },
|
||||
],
|
||||
});
|
||||
|
||||
const data = parseResult(result);
|
||||
const valuations = data.valuations as { index: number; valuation?: unknown; error?: string }[];
|
||||
expect(valuations).toHaveLength(2);
|
||||
expect(valuations[0].valuation).toBeDefined();
|
||||
expect(valuations[1].error).toContain('AVM service error');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user