test(analytics): add unit tests for AVM batch, history, comparison endpoints

Add comprehensive test coverage for the three AVM API upgrade endpoints:
- BatchValuationHandler: batch results, partial failures, error handling
- ValuationHistoryHandler: history retrieval, limit, empty state, errors
- ValuationComparisonHandler: multi-property compare, summary, edge cases
- AnalyticsController: route-level tests for all new endpoints

Fix async error handling in handlers by adding await to cache.getOrSet
calls so try/catch blocks properly catch rejections.

Fix pre-existing web test failures: add missing FLOOD_RISK_OPTIONS and
QUALITY_LABELS to valuation-form mock, update valuation-results assertions
to match current component rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:28:38 +07:00
parent ac4191cdf0
commit 74804757c5
9 changed files with 467 additions and 9 deletions

View File

@@ -0,0 +1,109 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import {
type IAVMService,
type BatchValuationResult,
type ValuationResult,
} from '../../domain/services/avm-service';
import { BatchValuationHandler } from '../queries/batch-valuation/batch-valuation.handler';
import { BatchValuationQuery } from '../queries/batch-valuation/batch-valuation.query';
describe('BatchValuationHandler', () => {
let handler: BatchValuationHandler;
let mockAvm: { [K in keyof IAVMService]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const sampleValuation: ValuationResult = {
estimatedPrice: '5000000000',
confidence: 0.85,
pricePerM2: 75000000,
comparables: [],
modelVersion: 'avm-v1.0',
};
const sampleBatchResult: BatchValuationResult[] = [
{ propertyId: 'prop-1', valuation: sampleValuation },
{ propertyId: 'prop-2', valuation: sampleValuation },
];
beforeEach(() => {
mockAvm = {
estimateValue: vi.fn(),
getComparables: vi.fn(),
estimateBatch: vi.fn(),
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new BatchValuationHandler(
mockAvm as any,
mockCache,
mockLogger as any,
);
});
it('returns batch valuation results for multiple properties', async () => {
mockAvm.estimateBatch.mockResolvedValue(sampleBatchResult);
const query = new BatchValuationQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result).toHaveLength(2);
expect(result[0]!.propertyId).toBe('prop-1');
expect(result[0]!.valuation!.estimatedPrice).toBe('5000000000');
expect(result[1]!.propertyId).toBe('prop-2');
expect(mockAvm.estimateBatch).toHaveBeenCalledWith([
{ propertyId: 'prop-1' },
{ propertyId: 'prop-2' },
]);
});
it('handles partial failures in batch results', async () => {
const partialResult: BatchValuationResult[] = [
{ propertyId: 'prop-1', valuation: sampleValuation },
{ propertyId: 'prop-2', valuation: null, error: 'Not found' },
];
mockAvm.estimateBatch.mockResolvedValue(partialResult);
const query = new BatchValuationQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result).toHaveLength(2);
expect(result[0]!.valuation).not.toBeNull();
expect(result[1]!.valuation).toBeNull();
expect(result[1]!.error).toBe('Not found');
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('VALIDATION_ERROR', 'Invalid input');
mockAvm.estimateBatch.mockRejectedValue(domainError);
const query = new BatchValuationQuery(['prop-1']);
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockAvm.estimateBatch.mockRejectedValue(new Error('Database timeout'));
const query = new BatchValuationQuery(['prop-1']);
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
it('passes single property batch correctly', async () => {
const singleResult: BatchValuationResult[] = [
{ propertyId: 'prop-solo', valuation: sampleValuation },
];
mockAvm.estimateBatch.mockResolvedValue(singleResult);
const query = new BatchValuationQuery(['prop-solo']);
const result = await handler.execute(query);
expect(result).toHaveLength(1);
expect(result[0]!.propertyId).toBe('prop-solo');
});
});

View File

@@ -0,0 +1,146 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService, type PrismaService } from '@modules/shared';
import { DomainException } from '@modules/shared';
import { type IAVMService, type ValuationResult } from '../../domain/services/avm-service';
import { ValuationComparisonHandler } from '../queries/valuation-comparison/valuation-comparison.handler';
import { ValuationComparisonQuery } from '../queries/valuation-comparison/valuation-comparison.query';
describe('ValuationComparisonHandler', () => {
let handler: ValuationComparisonHandler;
let mockAvm: { [K in keyof IAVMService]: ReturnType<typeof vi.fn> };
let mockPrisma: { property: { findMany: ReturnType<typeof vi.fn> } };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const makeValuation = (price: string, confidence: number, pricePerM2: number): ValuationResult => ({
estimatedPrice: price,
confidence,
pricePerM2,
comparables: [
{
propertyId: 'comp-1',
address: '123 Test',
district: 'Quận 1',
priceVND: price,
pricePerM2,
areaM2: 70,
propertyType: 'APARTMENT' as const,
distanceMeters: 200,
soldAt: '2026-03-01T00:00:00.000Z',
},
],
modelVersion: 'avm-v1.0',
});
const sampleProperties = [
{ id: 'prop-1', address: '10 Nguyễn Huệ', district: 'Quận 1', areaM2: 80, propertyType: 'APARTMENT' },
{ id: 'prop-2', address: '20 Lê Lợi', district: 'Quận 3', areaM2: 100, propertyType: 'HOUSE' },
];
beforeEach(() => {
mockAvm = {
estimateValue: vi.fn(),
getComparables: vi.fn(),
estimateBatch: vi.fn(),
};
mockPrisma = {
property: { findMany: vi.fn() },
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new ValuationComparisonHandler(
mockAvm as any,
mockPrisma as unknown as PrismaService,
mockCache,
mockLogger as any,
);
});
it('compares valuations for multiple properties with summary', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000))
.mockResolvedValueOnce(makeValuation('8000000000', 0.90, 80000000));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.propertyId).toBe('prop-1');
expect(result.properties[0]!.address).toBe('10 Nguyễn Huệ');
expect(result.properties[1]!.propertyId).toBe('prop-2');
// Summary checks
expect(result.summary.highestValue!.propertyId).toBe('prop-2');
expect(result.summary.highestValue!.estimatedPrice).toBe('8000000000');
expect(result.summary.lowestValue!.propertyId).toBe('prop-1');
expect(result.summary.lowestValue!.estimatedPrice).toBe('5000000000');
expect(result.summary.averagePricePerM2).toBe(77500000);
expect(result.summary.averageConfidence).toBe(0.88);
});
it('handles properties where valuation fails gracefully', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockResolvedValueOnce(makeValuation('5000000000', 0.85, 75000000))
.mockRejectedValueOnce(new Error('AI service timeout'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.valuation).not.toBeNull();
expect(result.properties[1]!.valuation).toBeNull();
// Summary should only reflect the successful valuation
expect(result.summary.highestValue!.propertyId).toBe('prop-1');
expect(result.summary.lowestValue!.propertyId).toBe('prop-1');
});
it('returns null summary values when all valuations fail', async () => {
mockPrisma.property.findMany.mockResolvedValue(sampleProperties);
mockAvm.estimateValue
.mockRejectedValueOnce(new Error('fail 1'))
.mockRejectedValueOnce(new Error('fail 2'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
const result = await handler.execute(query);
expect(result.summary.highestValue).toBeNull();
expect(result.summary.lowestValue).toBeNull();
expect(result.summary.averagePricePerM2).toBe(0);
expect(result.summary.averageConfidence).toBe(0);
});
it('handles unknown property IDs with empty details', async () => {
mockPrisma.property.findMany.mockResolvedValue([]);
mockAvm.estimateValue.mockRejectedValue(new Error('Not found'));
const query = new ValuationComparisonQuery(['unknown-1', 'unknown-2']);
const result = await handler.execute(query);
expect(result.properties).toHaveLength(2);
expect(result.properties[0]!.address).toBe('');
expect(result.properties[0]!.district).toBe('');
expect(result.properties[0]!.areaM2).toBe(0);
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('VALIDATION_ERROR', 'Too many properties');
mockPrisma.property.findMany.mockRejectedValue(domainError);
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockPrisma.property.findMany.mockRejectedValue(new Error('Connection refused'));
const query = new ValuationComparisonQuery(['prop-1', 'prop-2']);
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,111 @@
import { InternalServerErrorException } from '@nestjs/common';
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
import { DomainException } from '@modules/shared';
import { ValuationEntity } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';
import { ValuationHistoryHandler } from '../queries/valuation-history/valuation-history.handler';
import { ValuationHistoryQuery } from '../queries/valuation-history/valuation-history.query';
describe('ValuationHistoryHandler', () => {
let handler: ValuationHistoryHandler;
let mockRepo: { [K in keyof IValuationRepository]: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn> };
const createValuationEntity = (
id: string,
propertyId: string,
estimatedPrice: bigint,
confidence: number,
pricePerM2: number,
modelVersion: string,
createdAt: Date,
): ValuationEntity =>
new ValuationEntity(
id,
{ propertyId, estimatedPrice, confidence, pricePerM2, comparables: [], features: {}, modelVersion },
createdAt,
createdAt,
);
const sampleEntities = [
createValuationEntity('v1', 'prop-1', 5000000000n, 0.85, 75000000, 'avm-v1.0', new Date('2026-04-01')),
createValuationEntity('v2', 'prop-1', 5200000000n, 0.88, 78000000, 'avm-v1.1', new Date('2026-03-01')),
createValuationEntity('v3', 'prop-1', 4800000000n, 0.82, 72000000, 'avm-v1.0', new Date('2026-02-01')),
];
beforeEach(() => {
mockRepo = {
findById: vi.fn(),
findByPropertyId: vi.fn(),
findLatestByPropertyId: vi.fn(),
save: vi.fn(),
};
mockLogger = { error: vi.fn() };
const mockCache = {
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
} as unknown as CacheService;
handler = new ValuationHistoryHandler(
mockRepo as any,
mockCache,
mockLogger as any,
);
});
it('returns valuation history for a property', async () => {
mockRepo.findByPropertyId.mockResolvedValue(sampleEntities);
const query = new ValuationHistoryQuery('prop-1');
const result = await handler.execute(query);
expect(result.propertyId).toBe('prop-1');
expect(result.history).toHaveLength(3);
expect(result.totalRecords).toBe(3);
expect(result.history[0]!.estimatedPrice).toBe('5000000000');
expect(result.history[0]!.confidence).toBe(0.85);
expect(result.history[0]!.modelVersion).toBe('avm-v1.0');
});
it('respects the limit parameter', async () => {
mockRepo.findByPropertyId.mockResolvedValue(sampleEntities);
const query = new ValuationHistoryQuery('prop-1', 2);
const result = await handler.execute(query);
expect(result.history).toHaveLength(2);
expect(result.totalRecords).toBe(3);
});
it('returns empty history when no valuations exist', async () => {
mockRepo.findByPropertyId.mockResolvedValue([]);
const query = new ValuationHistoryQuery('prop-none');
const result = await handler.execute(query);
expect(result.propertyId).toBe('prop-none');
expect(result.history).toHaveLength(0);
expect(result.totalRecords).toBe(0);
});
it('re-throws DomainException directly', async () => {
const domainError = new DomainException('NOT_FOUND', 'Property not found');
mockRepo.findByPropertyId.mockRejectedValue(domainError);
const query = new ValuationHistoryQuery('prop-bad');
await expect(handler.execute(query)).rejects.toThrow(DomainException);
});
it('wraps unexpected errors in InternalServerErrorException', async () => {
mockRepo.findByPropertyId.mockRejectedValue(new Error('DB connection lost'));
const query = new ValuationHistoryQuery('prop-1');
await expect(handler.execute(query)).rejects.toThrow(InternalServerErrorException);
expect(mockLogger.error).toHaveBeenCalled();
});
it('uses default limit of 50', () => {
const query = new ValuationHistoryQuery('prop-1');
expect(query.limit).toBe(50);
});
});

View File

@@ -26,7 +26,7 @@ export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery>
...query.propertyIds.slice().sort(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
async () => {
const items = query.propertyIds.map((propertyId) => ({ propertyId }));

View File

@@ -37,7 +37,7 @@ export class ValuationComparisonHandler implements IQueryHandler<ValuationCompar
...query.propertyIds.slice().sort(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
() => this.buildComparison(query.propertyIds),
CacheTTL.MARKET_DATA,

View File

@@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQu
query.limit.toString(),
);
return this.cache.getOrSet(
return await this.cache.getOrSet(
cacheKey,
async () => {
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);

View File

@@ -1,8 +1,12 @@
import { type QueryBus } from '@nestjs/cqrs';
import { BatchValuationQuery } from '../../application/queries/batch-valuation/batch-valuation.query';
import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.query';
import { GetPriceTrendQuery } from '../../application/queries/get-price-trend/get-price-trend.query';
import { GetValuationQuery } from '../../application/queries/get-valuation/get-valuation.query';
import { ValuationComparisonQuery } from '../../application/queries/valuation-comparison/valuation-comparison.query';
import { ValuationHistoryQuery } from '../../application/queries/valuation-history/valuation-history.query';
import { AnalyticsController } from '../controllers/analytics.controller';
describe('AnalyticsController', () => {
@@ -76,4 +80,80 @@ describe('AnalyticsController', () => {
);
expect(result).toBe(expected);
});
it('getValuation executes GetValuationQuery with correct params', async () => {
const expected = { estimatedPrice: '5000000000', confidence: 0.85 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuation({
propertyId: 'prop-123',
latitude: undefined,
longitude: undefined,
areaM2: undefined,
propertyType: undefined,
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new GetValuationQuery('prop-123', undefined, undefined, undefined, undefined),
);
expect(result).toBe(expected);
});
it('batchValuation executes BatchValuationQuery with correct params', async () => {
const expected = [
{ propertyId: 'prop-1', valuation: { estimatedPrice: '5000000000' } },
{ propertyId: 'prop-2', valuation: { estimatedPrice: '6000000000' } },
];
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.batchValuation({
propertyIds: ['prop-1', 'prop-2'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new BatchValuationQuery(['prop-1', 'prop-2']),
);
expect(result).toBe(expected);
});
it('getValuationHistory executes ValuationHistoryQuery with correct params', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuationHistory('prop-1', { limit: 25 } as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 25),
);
expect(result).toBe(expected);
});
it('getValuationHistory defaults limit to 50', async () => {
const expected = { propertyId: 'prop-1', history: [], totalRecords: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getValuationHistory('prop-1', {} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationHistoryQuery('prop-1', 50),
);
expect(result).toBe(expected);
});
it('compareValuations executes ValuationComparisonQuery with correct params', async () => {
const expected = {
properties: [],
summary: { highestValue: null, lowestValue: null, averagePricePerM2: 0, averageConfidence: 0 },
};
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.compareValuations({
propertyIds: ['prop-1', 'prop-2', 'prop-3'],
} as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new ValuationComparisonQuery(['prop-1', 'prop-2', 'prop-3']),
);
expect(result).toBe(expected);
});
});

View File

@@ -27,6 +27,18 @@ vi.mock('@/lib/validations/valuation', () => ({
{ value: 'Ha Noi', label: 'Hà Nội' },
{ value: 'Da Nang', label: 'Đà Nẵng' },
],
FLOOD_RISK_OPTIONS: [
{ value: '0', label: 'An toàn' },
{ value: '0.5', label: 'Trung bình' },
{ value: '1', label: 'Rất cao' },
],
QUALITY_LABELS: {
renovationScore: 'Mức độ cải tạo',
viewQuality: 'Chất lượng view',
interiorQuality: 'Nội thất',
noiseLevel: 'Mức ồn',
naturalLight: 'Ánh sáng tự nhiên',
},
}));
function createWrapper() {

View File

@@ -64,25 +64,25 @@ describe('ValuationResults', () => {
it('renders price drivers section', () => {
render(<ValuationResults result={mockResult} />);
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
expect(screen.getByText('Vị trí trung tâm')).toBeInTheDocument();
expect(screen.getByText('Tầng thấp')).toBeInTheDocument();
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument();
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
});
it('shows positive driver with + sign', () => {
render(<ValuationResults result={mockResult} />);
expect(screen.getByText('+15.5%')).toBeInTheDocument();
expect(screen.getByText(/\+15\.5%/)).toBeInTheDocument();
});
it('shows negative driver with - sign', () => {
render(<ValuationResults result={mockResult} />);
expect(screen.getByText('-5.2%')).toBeInTheDocument();
expect(screen.getByText(/-5\.2%/)).toBeInTheDocument();
});
it('hides drivers section when empty', () => {
const noDrivers = { ...mockResult, priceDrivers: [] };
render(<ValuationResults result={noDrivers} />);
expect(screen.queryByText('Yếu tố ảnh hưởng giá')).not.toBeInTheDocument();
expect(screen.queryByText('Yếu tố chính')).not.toBeInTheDocument();
});
});