From a2e87c34e4acd80ad422b568422c564025e79991 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 16:31:35 +0700 Subject: [PATCH] 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 --- .../__tests__/market-analytics.server.test.ts | 221 +++++++++++++++ .../__tests__/property-search.server.test.ts | 233 ++++++++++++++++ .../src/__tests__/valuation.server.test.ts | 260 ++++++++++++++++++ 3 files changed, 714 insertions(+) create mode 100644 libs/mcp-servers/src/__tests__/market-analytics.server.test.ts create mode 100644 libs/mcp-servers/src/__tests__/property-search.server.test.ts create mode 100644 libs/mcp-servers/src/__tests__/valuation.server.test.ts diff --git a/libs/mcp-servers/src/__tests__/market-analytics.server.test.ts b/libs/mcp-servers/src/__tests__/market-analytics.server.test.ts new file mode 100644 index 0000000..27da75d --- /dev/null +++ b/libs/mcp-servers/src/__tests__/market-analytics.server.test.ts @@ -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): MarketAnalyticsDeps { + return { + typesenseClient: client as unknown as MarketAnalyticsDeps['typesenseClient'], + collectionName: 'test-listings', + }; +} + +function makeHits(docs: Record[]) { + return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 5 }; +} + +function getToolHandler(server: ReturnType, name: string) { + const tools = (server as unknown as { _registeredTools: Record Promise }> })._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; +} + +const now = Math.floor(Date.now() / 1000); +const oneMonthAgo = now - 30 * 24 * 3600; +const twoMonthsAgo = now - 60 * 24 * 3600; + +const makeListingDoc = (overrides: Record = {}) => ({ + 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; + let server: ReturnType; + + 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(); + }); + }); +}); diff --git a/libs/mcp-servers/src/__tests__/property-search.server.test.ts b/libs/mcp-servers/src/__tests__/property-search.server.test.ts new file mode 100644 index 0000000..6f44760 --- /dev/null +++ b/libs/mcp-servers/src/__tests__/property-search.server.test.ts @@ -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): PropertySearchDeps { + return { + typesenseClient: client as unknown as PropertySearchDeps['typesenseClient'], + collectionName: 'test-listings', + }; +} + +function makeHits(docs: Record[]) { + return { hits: docs.map((d) => ({ document: d })), found: docs.length, search_time_ms: 5 }; +} + +function getToolHandler(server: ReturnType, name: string) { + const tools = (server as unknown as { _registeredTools: Record Promise }> })._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; +} + +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; + let server: ReturnType; + + 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[])[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; + + expect(data.properties).toHaveLength(2); + const comparison = data.comparison as Record>; + 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'); + }); + }); +}); diff --git a/libs/mcp-servers/src/__tests__/valuation.server.test.ts b/libs/mcp-servers/src/__tests__/valuation.server.test.ts new file mode 100644 index 0000000..bd6c776 --- /dev/null +++ b/libs/mcp-servers/src/__tests__/valuation.server.test.ts @@ -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, name: string) { + const tools = (server as unknown as { _registeredTools: Record Promise }> })._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; +} + +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; + let fetchMock: ReturnType; + + 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; + 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; + 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 }[]; + 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'); + }); + }); +});