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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export class BatchValuationHandler implements IQueryHandler<BatchValuationQuery>
|
|||||||
...query.propertyIds.slice().sort(),
|
...query.propertyIds.slice().sort(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
const items = query.propertyIds.map((propertyId) => ({ propertyId }));
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class ValuationComparisonHandler implements IQueryHandler<ValuationCompar
|
|||||||
...query.propertyIds.slice().sort(),
|
...query.propertyIds.slice().sort(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
() => this.buildComparison(query.propertyIds),
|
() => this.buildComparison(query.propertyIds),
|
||||||
CacheTTL.MARKET_DATA,
|
CacheTTL.MARKET_DATA,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class ValuationHistoryHandler implements IQueryHandler<ValuationHistoryQu
|
|||||||
query.limit.toString(),
|
query.limit.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cache.getOrSet(
|
return await this.cache.getOrSet(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
const entities = await this.valuationRepo.findByPropertyId(query.propertyId);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { type QueryBus } from '@nestjs/cqrs';
|
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 { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../../application/queries/get-heatmap/get-heatmap.query';
|
||||||
import { GetMarketReportQuery } from '../../application/queries/get-market-report/get-market-report.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 { 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';
|
import { AnalyticsController } from '../controllers/analytics.controller';
|
||||||
|
|
||||||
describe('AnalyticsController', () => {
|
describe('AnalyticsController', () => {
|
||||||
@@ -76,4 +80,80 @@ describe('AnalyticsController', () => {
|
|||||||
);
|
);
|
||||||
expect(result).toBe(expected);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ vi.mock('@/lib/validations/valuation', () => ({
|
|||||||
{ value: 'Ha Noi', label: 'Hà Nội' },
|
{ value: 'Ha Noi', label: 'Hà Nội' },
|
||||||
{ value: 'Da Nang', label: 'Đà Nẵng' },
|
{ 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() {
|
function createWrapper() {
|
||||||
|
|||||||
@@ -64,25 +64,25 @@ describe('ValuationResults', () => {
|
|||||||
|
|
||||||
it('renders price drivers section', () => {
|
it('renders price drivers section', () => {
|
||||||
render(<ValuationResults result={mockResult} />);
|
render(<ValuationResults result={mockResult} />);
|
||||||
expect(screen.getByText('Yếu tố ảnh hưởng giá')).toBeInTheDocument();
|
expect(screen.getByText('Yếu tố chính')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Vị trí trung tâm')).toBeInTheDocument();
|
expect(screen.getByText(/Vị trí trung tâm/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Tầng thấp')).toBeInTheDocument();
|
expect(screen.getByText(/Tầng thấp/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows positive driver with + sign', () => {
|
it('shows positive driver with + sign', () => {
|
||||||
render(<ValuationResults result={mockResult} />);
|
render(<ValuationResults result={mockResult} />);
|
||||||
expect(screen.getByText('+15.5%')).toBeInTheDocument();
|
expect(screen.getByText(/\+15\.5%/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows negative driver with - sign', () => {
|
it('shows negative driver with - sign', () => {
|
||||||
render(<ValuationResults result={mockResult} />);
|
render(<ValuationResults result={mockResult} />);
|
||||||
expect(screen.getByText('-5.2%')).toBeInTheDocument();
|
expect(screen.getByText(/-5\.2%/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides drivers section when empty', () => {
|
it('hides drivers section when empty', () => {
|
||||||
const noDrivers = { ...mockResult, priceDrivers: [] };
|
const noDrivers = { ...mockResult, priceDrivers: [] };
|
||||||
render(<ValuationResults result={noDrivers} />);
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user