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:
Ho Ngoc Hai
2026-04-08 16:31:35 +07:00
parent 8705a2d9a8
commit a2e87c34e4
3 changed files with 714 additions and 0 deletions

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

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

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