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:
Ho Ngoc Hai
2026-04-08 23:07:14 +07:00
parent 7fb25eb2b1
commit c9782fd48d
18 changed files with 2097 additions and 0 deletions

View File

@@ -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,
});
});
});

View File

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