test(api): add unit tests for analytics, metrics, notifications, payments, and search modules
New test coverage for infrastructure and presentation layers across multiple modules including Momo/ZaloPay payment services, Typesense search repository, listing indexer, and notification handlers. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { MarketIndexEntity } from '../entities/market-index.entity';
|
||||
import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event';
|
||||
|
||||
describe('MarketIndexEntity', () => {
|
||||
it('createNew creates entity with all properties and adds MarketIndexUpdatedEvent', () => {
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-1',
|
||||
'Quận 1',
|
||||
'Hồ Chí Minh',
|
||||
'APARTMENT',
|
||||
'2026-Q1',
|
||||
BigInt(5000000000),
|
||||
80000000,
|
||||
150,
|
||||
45,
|
||||
3,
|
||||
0.65,
|
||||
0.08,
|
||||
);
|
||||
|
||||
expect(entity.id).toBe('idx-1');
|
||||
expect(entity.district).toBe('Quận 1');
|
||||
expect(entity.city).toBe('Hồ Chí Minh');
|
||||
expect(entity.propertyType).toBe('APARTMENT');
|
||||
expect(entity.period).toBe('2026-Q1');
|
||||
expect(entity.medianPrice).toBe(BigInt(5000000000));
|
||||
expect(entity.avgPriceM2).toBe(80000000);
|
||||
expect(entity.totalListings).toBe(150);
|
||||
expect(entity.daysOnMarket).toBe(45);
|
||||
expect(entity.inventoryLevel).toBe(3);
|
||||
expect(entity.absorptionRate).toBe(0.65);
|
||||
expect(entity.yoyChange).toBe(0.08);
|
||||
|
||||
const events = entity.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBeInstanceOf(MarketIndexUpdatedEvent);
|
||||
const event = events[0] as MarketIndexUpdatedEvent;
|
||||
expect(event.aggregateId).toBe('idx-1');
|
||||
expect(event.district).toBe('Quận 1');
|
||||
expect(event.city).toBe('Hồ Chí Minh');
|
||||
expect(event.period).toBe('2026-Q1');
|
||||
});
|
||||
|
||||
it('createNew handles optional absorptionRate and yoyChange (null when not provided)', () => {
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-2',
|
||||
'Quận 2',
|
||||
'Hồ Chí Minh',
|
||||
'VILLA',
|
||||
'2026-Q1',
|
||||
BigInt(8000000000),
|
||||
120000000,
|
||||
50,
|
||||
60,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(entity.absorptionRate).toBeNull();
|
||||
expect(entity.yoyChange).toBeNull();
|
||||
});
|
||||
|
||||
it('updateMetrics updates all metric fields and adds MarketIndexUpdatedEvent', () => {
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-3',
|
||||
'Quận 3',
|
||||
'Hồ Chí Minh',
|
||||
'APARTMENT',
|
||||
'2026-Q1',
|
||||
BigInt(5000000000),
|
||||
80000000,
|
||||
150,
|
||||
45,
|
||||
3,
|
||||
0.65,
|
||||
0.08,
|
||||
);
|
||||
entity.clearDomainEvents();
|
||||
|
||||
entity.updateMetrics(
|
||||
BigInt(6000000000),
|
||||
90000000,
|
||||
200,
|
||||
40,
|
||||
4,
|
||||
0.70,
|
||||
0.10,
|
||||
);
|
||||
|
||||
expect(entity.medianPrice).toBe(BigInt(6000000000));
|
||||
expect(entity.avgPriceM2).toBe(90000000);
|
||||
expect(entity.totalListings).toBe(200);
|
||||
expect(entity.daysOnMarket).toBe(40);
|
||||
expect(entity.inventoryLevel).toBe(4);
|
||||
expect(entity.absorptionRate).toBe(0.70);
|
||||
expect(entity.yoyChange).toBe(0.10);
|
||||
|
||||
const events = entity.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBeInstanceOf(MarketIndexUpdatedEvent);
|
||||
});
|
||||
|
||||
it('updateMetrics preserves existing absorptionRate when not provided', () => {
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-4',
|
||||
'Quận 4',
|
||||
'Hồ Chí Minh',
|
||||
'APARTMENT',
|
||||
'2026-Q1',
|
||||
BigInt(5000000000),
|
||||
80000000,
|
||||
150,
|
||||
45,
|
||||
3,
|
||||
0.65,
|
||||
0.08,
|
||||
);
|
||||
|
||||
entity.updateMetrics(
|
||||
BigInt(5500000000),
|
||||
85000000,
|
||||
160,
|
||||
42,
|
||||
3,
|
||||
);
|
||||
|
||||
expect(entity.absorptionRate).toBe(0.65);
|
||||
expect(entity.yoyChange).toBe(0.08);
|
||||
});
|
||||
|
||||
it('constructor sets all properties from props', () => {
|
||||
const createdAt = new Date('2026-01-01');
|
||||
const updatedAt = new Date('2026-03-01');
|
||||
const entity = new MarketIndexEntity(
|
||||
'idx-5',
|
||||
{
|
||||
district: 'Bình Thạnh',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'TOWNHOUSE',
|
||||
period: '2026-Q1',
|
||||
medianPrice: BigInt(3000000000),
|
||||
avgPriceM2: 60000000,
|
||||
totalListings: 80,
|
||||
daysOnMarket: 30,
|
||||
inventoryLevel: 2,
|
||||
absorptionRate: 0.50,
|
||||
yoyChange: 0.05,
|
||||
},
|
||||
createdAt,
|
||||
updatedAt,
|
||||
);
|
||||
|
||||
expect(entity.id).toBe('idx-5');
|
||||
expect(entity.district).toBe('Bình Thạnh');
|
||||
expect(entity.city).toBe('Hồ Chí Minh');
|
||||
expect(entity.propertyType).toBe('TOWNHOUSE');
|
||||
expect(entity.period).toBe('2026-Q1');
|
||||
expect(entity.medianPrice).toBe(BigInt(3000000000));
|
||||
expect(entity.avgPriceM2).toBe(60000000);
|
||||
expect(entity.totalListings).toBe(80);
|
||||
expect(entity.daysOnMarket).toBe(30);
|
||||
expect(entity.inventoryLevel).toBe(2);
|
||||
expect(entity.absorptionRate).toBe(0.50);
|
||||
expect(entity.yoyChange).toBe(0.05);
|
||||
expect(entity.createdAt).toBe(createdAt);
|
||||
expect(entity.domainEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ValuationEntity } from '../entities/valuation.entity';
|
||||
|
||||
describe('ValuationEntity', () => {
|
||||
const baseProps = {
|
||||
propertyId: 'prop-1',
|
||||
estimatedPrice: BigInt(5000000000),
|
||||
confidence: 0.85,
|
||||
pricePerM2: 50000000,
|
||||
comparables: [{ id: 'comp-1', price: 4800000000 }],
|
||||
features: { area: 100, bedrooms: 3 },
|
||||
modelVersion: 'v1',
|
||||
};
|
||||
|
||||
it('createNew creates entity with all properties', () => {
|
||||
const entity = ValuationEntity.createNew(
|
||||
'val-1',
|
||||
baseProps.propertyId,
|
||||
baseProps.estimatedPrice,
|
||||
baseProps.confidence,
|
||||
baseProps.pricePerM2,
|
||||
baseProps.comparables,
|
||||
baseProps.features,
|
||||
baseProps.modelVersion,
|
||||
);
|
||||
|
||||
expect(entity.id).toBe('val-1');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.estimatedPrice).toBe(BigInt(5000000000));
|
||||
expect(entity.confidence).toBe(0.85);
|
||||
expect(entity.pricePerM2).toBe(50000000);
|
||||
expect(entity.comparables).toEqual(baseProps.comparables);
|
||||
expect(entity.features).toEqual(baseProps.features);
|
||||
expect(entity.modelVersion).toBe('v1');
|
||||
});
|
||||
|
||||
it('constructor sets all properties correctly', () => {
|
||||
const createdAt = new Date('2026-01-15');
|
||||
const entity = new ValuationEntity('val-2', baseProps, createdAt);
|
||||
|
||||
expect(entity.id).toBe('val-2');
|
||||
expect(entity.propertyId).toBe('prop-1');
|
||||
expect(entity.estimatedPrice).toBe(BigInt(5000000000));
|
||||
expect(entity.confidence).toBe(0.85);
|
||||
expect(entity.pricePerM2).toBe(50000000);
|
||||
expect(entity.comparables).toEqual(baseProps.comparables);
|
||||
expect(entity.features).toEqual(baseProps.features);
|
||||
expect(entity.modelVersion).toBe('v1');
|
||||
expect(entity.createdAt).toBe(createdAt);
|
||||
});
|
||||
|
||||
it('all getters return expected values', () => {
|
||||
const entity = new ValuationEntity('val-3', {
|
||||
propertyId: 'prop-99',
|
||||
estimatedPrice: BigInt(9999000000),
|
||||
confidence: 0.92,
|
||||
pricePerM2: 99000000,
|
||||
comparables: [],
|
||||
features: { location: 'central' },
|
||||
modelVersion: 'v2',
|
||||
});
|
||||
|
||||
expect(entity.propertyId).toBe('prop-99');
|
||||
expect(entity.estimatedPrice).toBe(BigInt(9999000000));
|
||||
expect(entity.confidence).toBe(0.92);
|
||||
expect(entity.pricePerM2).toBe(99000000);
|
||||
expect(entity.comparables).toEqual([]);
|
||||
expect(entity.features).toEqual({ location: 'central' });
|
||||
expect(entity.modelVersion).toBe('v2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { MarketIndexEntity } from '../../domain/entities/market-index.entity';
|
||||
import { PrismaMarketIndexRepository } from '../repositories/prisma-market-index.repository';
|
||||
|
||||
const makeRawRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'idx-1',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT' as const,
|
||||
period: '2026-Q1',
|
||||
medianPrice: BigInt(5000000000),
|
||||
avgPriceM2: 50,
|
||||
totalListings: 100,
|
||||
daysOnMarket: 30,
|
||||
inventoryLevel: 5,
|
||||
absorptionRate: 0.8,
|
||||
yoyChange: 0.1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PrismaMarketIndexRepository', () => {
|
||||
let repo: PrismaMarketIndexRepository;
|
||||
let mockPrisma: {
|
||||
marketIndex: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
marketIndex: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
repo = new PrismaMarketIndexRepository(mockPrisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
it('findById returns entity when found', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.marketIndex.findUnique.mockResolvedValue(raw);
|
||||
|
||||
const result = await repo.findById('idx-1');
|
||||
|
||||
expect(result).toBeInstanceOf(MarketIndexEntity);
|
||||
expect(result?.id).toBe('idx-1');
|
||||
expect(result?.district).toBe('Quận 1');
|
||||
expect(result?.medianPrice).toBe(BigInt(5000000000));
|
||||
expect(mockPrisma.marketIndex.findUnique).toHaveBeenCalledWith({ where: { id: 'idx-1' } });
|
||||
});
|
||||
|
||||
it('findById returns null when not found', async () => {
|
||||
mockPrisma.marketIndex.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('findByKey returns entity when found', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.marketIndex.findUnique.mockResolvedValue(raw);
|
||||
|
||||
const result = await repo.findByKey('Quận 1', 'Hồ Chí Minh', 'APARTMENT', '2026-Q1');
|
||||
|
||||
expect(result).toBeInstanceOf(MarketIndexEntity);
|
||||
expect(result?.district).toBe('Quận 1');
|
||||
expect(mockPrisma.marketIndex.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
district_city_propertyType_period: {
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
period: '2026-Q1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('save calls prisma.marketIndex.create with correct data', async () => {
|
||||
mockPrisma.marketIndex.create.mockResolvedValue(undefined);
|
||||
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-new',
|
||||
'Quận 2',
|
||||
'Hồ Chí Minh',
|
||||
'APARTMENT',
|
||||
'2026-Q1',
|
||||
BigInt(5000000000),
|
||||
50,
|
||||
100,
|
||||
30,
|
||||
5,
|
||||
0.8,
|
||||
0.1,
|
||||
);
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
expect(mockPrisma.marketIndex.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
id: 'idx-new',
|
||||
district: 'Quận 2',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
period: '2026-Q1',
|
||||
medianPrice: BigInt(5000000000),
|
||||
avgPriceM2: 50,
|
||||
totalListings: 100,
|
||||
daysOnMarket: 30,
|
||||
inventoryLevel: 5,
|
||||
absorptionRate: 0.8,
|
||||
yoyChange: 0.1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('update calls prisma.marketIndex.update with correct data', async () => {
|
||||
mockPrisma.marketIndex.update.mockResolvedValue(undefined);
|
||||
|
||||
const entity = MarketIndexEntity.createNew(
|
||||
'idx-upd',
|
||||
'Quận 3',
|
||||
'Hồ Chí Minh',
|
||||
'VILLA',
|
||||
'2026-Q1',
|
||||
BigInt(7000000000),
|
||||
90,
|
||||
60,
|
||||
20,
|
||||
2,
|
||||
0.5,
|
||||
0.05,
|
||||
);
|
||||
|
||||
await repo.update(entity);
|
||||
|
||||
expect(mockPrisma.marketIndex.update).toHaveBeenCalledWith({
|
||||
where: { id: 'idx-upd' },
|
||||
data: {
|
||||
medianPrice: BigInt(7000000000),
|
||||
avgPriceM2: 90,
|
||||
totalListings: 60,
|
||||
daysOnMarket: 20,
|
||||
inventoryLevel: 2,
|
||||
absorptionRate: 0.5,
|
||||
yoyChange: 0.05,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('getMarketReport returns mapped results', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.marketIndex.findMany.mockResolvedValue([raw]);
|
||||
|
||||
const results = await repo.getMarketReport('Hồ Chí Minh', '2026-Q1');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
period: '2026-Q1',
|
||||
medianPrice: '5000000000',
|
||||
avgPriceM2: 50,
|
||||
totalListings: 100,
|
||||
daysOnMarket: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('getMarketReport filters by propertyType when provided', async () => {
|
||||
mockPrisma.marketIndex.findMany.mockResolvedValue([]);
|
||||
|
||||
await repo.getMarketReport('Hồ Chí Minh', '2026-Q1', 'VILLA');
|
||||
|
||||
expect(mockPrisma.marketIndex.findMany).toHaveBeenCalledWith({
|
||||
where: { city: 'Hồ Chí Minh', period: '2026-Q1', propertyType: 'VILLA' },
|
||||
orderBy: { district: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('getHeatmap aggregates by district correctly', async () => {
|
||||
const records = [
|
||||
makeRawRecord({ district: 'Quận 1', avgPriceM2: 80, totalListings: 100, medianPrice: BigInt(5000000000) }),
|
||||
makeRawRecord({ district: 'Quận 1', avgPriceM2: 60, totalListings: 50, medianPrice: BigInt(4000000000) }),
|
||||
makeRawRecord({ district: 'Quận 2', avgPriceM2: 70, totalListings: 80, medianPrice: BigInt(4500000000) }),
|
||||
];
|
||||
mockPrisma.marketIndex.findMany.mockResolvedValue(records);
|
||||
|
||||
const results = await repo.getHeatmap('Hồ Chí Minh', '2026-Q1');
|
||||
|
||||
const quan1 = results.find((r) => r.district === 'Quận 1');
|
||||
const quan2 = results.find((r) => r.district === 'Quận 2');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(quan1).toBeDefined();
|
||||
expect(quan1?.avgPriceM2).toBe(70); // (80 + 60) / 2
|
||||
expect(quan1?.totalListings).toBe(150); // 100 + 50
|
||||
expect(quan2).toBeDefined();
|
||||
expect(quan2?.avgPriceM2).toBe(70);
|
||||
});
|
||||
|
||||
it('getPriceTrend returns mapped results', async () => {
|
||||
const raw = makeRawRecord({ period: '2025-Q4' });
|
||||
mockPrisma.marketIndex.findMany.mockResolvedValue([raw]);
|
||||
|
||||
const results = await repo.getPriceTrend('Quận 1', 'Hồ Chí Minh', 'APARTMENT', ['2025-Q4', '2026-Q1']);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
period: '2025-Q4',
|
||||
medianPrice: '5000000000',
|
||||
avgPriceM2: 50,
|
||||
totalListings: 100,
|
||||
});
|
||||
expect(mockPrisma.marketIndex.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
period: { in: ['2025-Q4', '2026-Q1'] },
|
||||
},
|
||||
orderBy: { period: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('getDistrictStats returns mapped results', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.marketIndex.findMany.mockResolvedValue([raw]);
|
||||
|
||||
const results = await repo.getDistrictStats('Hồ Chí Minh', '2026-Q1');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
medianPrice: '5000000000',
|
||||
avgPriceM2: 50,
|
||||
totalListings: 100,
|
||||
daysOnMarket: 30,
|
||||
inventoryLevel: 5,
|
||||
absorptionRate: 0.8,
|
||||
yoyChange: 0.1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { ValuationEntity } from '../../domain/entities/valuation.entity';
|
||||
import { PrismaValuationRepository } from '../repositories/prisma-valuation.repository';
|
||||
|
||||
const makeRawRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'val-1',
|
||||
propertyId: 'prop-1',
|
||||
estimatedPrice: BigInt(5000000000),
|
||||
confidence: 0.85,
|
||||
pricePerM2: 50,
|
||||
comparables: [],
|
||||
features: {},
|
||||
modelVersion: 'v1',
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('PrismaValuationRepository', () => {
|
||||
let repo: PrismaValuationRepository;
|
||||
let mockPrisma: {
|
||||
valuation: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
valuation: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
repo = new PrismaValuationRepository(mockPrisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
it('findById returns entity when found', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.valuation.findUnique.mockResolvedValue(raw);
|
||||
|
||||
const result = await repo.findById('val-1');
|
||||
|
||||
expect(result).toBeInstanceOf(ValuationEntity);
|
||||
expect(result?.id).toBe('val-1');
|
||||
expect(result?.propertyId).toBe('prop-1');
|
||||
expect(result?.estimatedPrice).toBe(BigInt(5000000000));
|
||||
expect(mockPrisma.valuation.findUnique).toHaveBeenCalledWith({ where: { id: 'val-1' } });
|
||||
});
|
||||
|
||||
it('findById returns null when not found', async () => {
|
||||
mockPrisma.valuation.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('findByPropertyId returns array of entities', async () => {
|
||||
const raw1 = makeRawRecord({ id: 'val-1' });
|
||||
const raw2 = makeRawRecord({ id: 'val-2', estimatedPrice: BigInt(6000000000) });
|
||||
mockPrisma.valuation.findMany.mockResolvedValue([raw1, raw2]);
|
||||
|
||||
const results = await repo.findByPropertyId('prop-1');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toBeInstanceOf(ValuationEntity);
|
||||
expect(results[1]).toBeInstanceOf(ValuationEntity);
|
||||
expect(results[0].id).toBe('val-1');
|
||||
expect(results[1].id).toBe('val-2');
|
||||
expect(mockPrisma.valuation.findMany).toHaveBeenCalledWith({
|
||||
where: { propertyId: 'prop-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('findLatestByPropertyId returns entity when found', async () => {
|
||||
const raw = makeRawRecord();
|
||||
mockPrisma.valuation.findFirst.mockResolvedValue(raw);
|
||||
|
||||
const result = await repo.findLatestByPropertyId('prop-1');
|
||||
|
||||
expect(result).toBeInstanceOf(ValuationEntity);
|
||||
expect(result?.id).toBe('val-1');
|
||||
expect(mockPrisma.valuation.findFirst).toHaveBeenCalledWith({
|
||||
where: { propertyId: 'prop-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('findLatestByPropertyId returns null when not found', async () => {
|
||||
mockPrisma.valuation.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await repo.findLatestByPropertyId('prop-missing');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('save calls prisma.valuation.create with correct data', async () => {
|
||||
mockPrisma.valuation.create.mockResolvedValue(undefined);
|
||||
|
||||
const entity = ValuationEntity.createNew(
|
||||
'val-new',
|
||||
'prop-2',
|
||||
BigInt(7000000000),
|
||||
0.90,
|
||||
70,
|
||||
[{ id: 'c1' }],
|
||||
{ bedrooms: 4 },
|
||||
'v2',
|
||||
);
|
||||
|
||||
await repo.save(entity);
|
||||
|
||||
expect(mockPrisma.valuation.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
id: 'val-new',
|
||||
propertyId: 'prop-2',
|
||||
estimatedPrice: BigInt(7000000000),
|
||||
confidence: 0.90,
|
||||
pricePerM2: 70,
|
||||
comparables: [{ id: 'c1' }],
|
||||
features: { bedrooms: 4 },
|
||||
modelVersion: 'v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type QueryBus } from '@nestjs/cqrs';
|
||||
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 { AnalyticsController } from '../controllers/analytics.controller';
|
||||
|
||||
describe('AnalyticsController', () => {
|
||||
let controller: AnalyticsController;
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
controller = new AnalyticsController(mockQueryBus as unknown as QueryBus);
|
||||
});
|
||||
|
||||
it('getMarketReport executes GetMarketReportQuery with correct params', async () => {
|
||||
const expected = { city: 'Hồ Chí Minh', period: '2026-Q1', districts: [] };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getMarketReport({
|
||||
city: 'Hồ Chí Minh',
|
||||
period: '2026-Q1',
|
||||
propertyType: 'APARTMENT',
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GetMarketReportQuery('Hồ Chí Minh', '2026-Q1', 'APARTMENT'),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getPriceTrend executes GetPriceTrendQuery with correct params', async () => {
|
||||
const expected = { district: 'Quận 1', city: 'Hồ Chí Minh', trend: [] };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getPriceTrend({
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
propertyType: 'APARTMENT',
|
||||
periods: ['2025-Q4', '2026-Q1'],
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GetPriceTrendQuery('Quận 1', 'Hồ Chí Minh', 'APARTMENT', ['2025-Q4', '2026-Q1']),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getHeatmap executes GetHeatmapQuery with correct params', async () => {
|
||||
const expected = { city: 'Hồ Chí Minh', data: [] };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getHeatmap({
|
||||
city: 'Hồ Chí Minh',
|
||||
period: '2026-Q1',
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1'),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('getDistrictStats executes GetDistrictStatsQuery with correct params', async () => {
|
||||
const expected = { city: 'Hà Nội', stats: [] };
|
||||
mockQueryBus.execute.mockResolvedValue(expected);
|
||||
|
||||
const result = await controller.getDistrictStats({
|
||||
city: 'Hà Nội',
|
||||
period: '2026-Q1',
|
||||
} as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GetDistrictStatsQuery('Hà Nội', '2026-Q1'),
|
||||
);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user