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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type Counter, type Gauge, type Histogram } from 'prom-client';
|
||||
import { MetricsService } from '../metrics.service';
|
||||
|
||||
describe('MetricsService', () => {
|
||||
let service: MetricsService;
|
||||
let mockListingsCreatedCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockPaymentsProcessedCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockActiveSubscriptionsGauge: { set: ReturnType<typeof vi.fn> };
|
||||
let mockSearchQueriesCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
let mockRequestDurationHistogram: { observe: ReturnType<typeof vi.fn> };
|
||||
let mockHttpRequestsCounter: { inc: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingsCreatedCounter = { inc: vi.fn() };
|
||||
mockPaymentsProcessedCounter = { inc: vi.fn() };
|
||||
mockActiveSubscriptionsGauge = { set: vi.fn() };
|
||||
mockSearchQueriesCounter = { inc: vi.fn() };
|
||||
mockRequestDurationHistogram = { observe: vi.fn() };
|
||||
mockHttpRequestsCounter = { inc: vi.fn() };
|
||||
|
||||
service = new MetricsService(
|
||||
mockListingsCreatedCounter as unknown as Counter,
|
||||
mockPaymentsProcessedCounter as unknown as Counter,
|
||||
mockActiveSubscriptionsGauge as unknown as Gauge,
|
||||
mockSearchQueriesCounter as unknown as Counter,
|
||||
mockRequestDurationHistogram as unknown as Histogram,
|
||||
mockHttpRequestsCounter as unknown as Counter,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordListingCreated increments listingsCreatedCounter with the given category', () => {
|
||||
service.recordListingCreated('apartment');
|
||||
|
||||
expect(mockListingsCreatedCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockListingsCreatedCounter.inc).toHaveBeenCalledWith({ category: 'apartment' });
|
||||
});
|
||||
|
||||
it('recordPaymentProcessed increments paymentsProcessedCounter with status and method', () => {
|
||||
service.recordPaymentProcessed('success', 'vnpay');
|
||||
|
||||
expect(mockPaymentsProcessedCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockPaymentsProcessedCounter.inc).toHaveBeenCalledWith({
|
||||
status: 'success',
|
||||
method: 'vnpay',
|
||||
});
|
||||
});
|
||||
|
||||
it('setActiveSubscriptions sets the gauge with plan and count', () => {
|
||||
service.setActiveSubscriptions('pro', 42);
|
||||
|
||||
expect(mockActiveSubscriptionsGauge.set).toHaveBeenCalledOnce();
|
||||
expect(mockActiveSubscriptionsGauge.set).toHaveBeenCalledWith({ plan: 'pro' }, 42);
|
||||
});
|
||||
|
||||
it('recordSearchQuery increments searchQueriesCounter with collection and type', () => {
|
||||
service.recordSearchQuery('properties', 'geo');
|
||||
|
||||
expect(mockSearchQueriesCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockSearchQueriesCounter.inc).toHaveBeenCalledWith({
|
||||
collection: 'properties',
|
||||
type: 'geo',
|
||||
});
|
||||
});
|
||||
|
||||
it('recordHttpRequest observes requestDurationHistogram with correct labels and duration', () => {
|
||||
service.recordHttpRequest('GET', '/api/listings', 200, 0.123);
|
||||
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledOnce();
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(
|
||||
{ method: 'GET', route: '/api/listings', status_code: '200' },
|
||||
0.123,
|
||||
);
|
||||
});
|
||||
|
||||
it('recordHttpRequest increments httpRequestsCounter with correct labels', () => {
|
||||
service.recordHttpRequest('POST', '/api/payments', 201, 0.456);
|
||||
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledOnce();
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith({
|
||||
method: 'POST',
|
||||
route: '/api/payments',
|
||||
status_code: '201',
|
||||
});
|
||||
});
|
||||
|
||||
it('recordHttpRequest calls both histogram and counter with the same labels', () => {
|
||||
service.recordHttpRequest('DELETE', '/api/listings/1', 204, 0.05);
|
||||
|
||||
const expectedLabels = { method: 'DELETE', route: '/api/listings/1', status_code: '204' };
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(expectedLabels, 0.05);
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith(expectedLabels);
|
||||
});
|
||||
|
||||
it('recordHttpRequest converts numeric statusCode to string in labels', () => {
|
||||
service.recordHttpRequest('GET', '/api/health', 503, 0.001);
|
||||
|
||||
expect(mockRequestDurationHistogram.observe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
0.001,
|
||||
);
|
||||
expect(mockHttpRequestsCounter.inc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status_code: '503' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { type CallHandler, type ExecutionContext } from '@nestjs/common';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { MetricsService } from '../../../infrastructure/metrics.service';
|
||||
import { HttpMetricsInterceptor } from '../http-metrics.interceptor';
|
||||
|
||||
describe('HttpMetricsInterceptor', () => {
|
||||
let interceptor: HttpMetricsInterceptor;
|
||||
let mockMetricsService: { recordHttpRequest: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockMetricsService = { recordHttpRequest: vi.fn() };
|
||||
interceptor = new HttpMetricsInterceptor(mockMetricsService as unknown as MetricsService);
|
||||
});
|
||||
|
||||
function createContext(
|
||||
requestOverrides: Record<string, unknown> = {},
|
||||
responseOverrides: Record<string, unknown> = {},
|
||||
): ExecutionContext {
|
||||
const mockRequest = {
|
||||
method: 'GET',
|
||||
path: '/api/listings',
|
||||
route: { path: '/api/listings/:id' },
|
||||
...requestOverrides,
|
||||
};
|
||||
const mockResponse = {
|
||||
statusCode: 200,
|
||||
...responseOverrides,
|
||||
};
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => mockRequest,
|
||||
getResponse: () => mockResponse,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
it('records metrics on successful request', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/listings', route: { path: '/api/listings/:id' } },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledOnce();
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/listings/:id',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('records metrics on error request', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'POST', path: '/api/payments', route: { path: '/api/payments' } },
|
||||
{ statusCode: 500 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => throwError(() => new Error('test')) };
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledOnce();
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/api/payments',
|
||||
500,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses request.path when request.route is undefined', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/search', route: undefined },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/search',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses request.route.path when available', async () => {
|
||||
const context = createContext(
|
||||
{ method: 'GET', path: '/api/listings/abc123', route: { path: '/api/listings/:id' } },
|
||||
{ statusCode: 200 },
|
||||
);
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMetricsService.recordHttpRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/api/listings/:id',
|
||||
200,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a non-negative duration in seconds', async () => {
|
||||
const context = createContext({}, { statusCode: 200 });
|
||||
const next: CallHandler = { handle: () => of(undefined) };
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
interceptor.intercept(context, next).subscribe({
|
||||
next: () => resolve(),
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const [, , , durationSeconds] = mockMetricsService.recordHttpRequest.mock.calls[0] as [
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
expect(durationSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { EmailService } from '../services/email.service';
|
||||
|
||||
vi.mock('nodemailer');
|
||||
|
||||
describe('EmailService', () => {
|
||||
let service: EmailService;
|
||||
let mockTransporter: { sendMail: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransporter = {
|
||||
sendMail: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(nodemailer.createTransport).mockReturnValue(mockTransporter as any);
|
||||
|
||||
mockLogger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
|
||||
|
||||
service = new EmailService(mockLogger as any);
|
||||
service.onModuleInit();
|
||||
});
|
||||
|
||||
it('send sends email and returns messageId', async () => {
|
||||
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-message-id' });
|
||||
|
||||
const result = await service.send({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
html: '<p>Test body</p>',
|
||||
});
|
||||
|
||||
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
html: '<p>Test body</p>',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ messageId: 'test-message-id' });
|
||||
});
|
||||
|
||||
it('send throws error when sendMail fails', async () => {
|
||||
const smtpError = new Error('SMTP connection refused');
|
||||
mockTransporter.sendMail.mockRejectedValue(smtpError);
|
||||
|
||||
await expect(
|
||||
service.send({ to: 'test@example.com', subject: 'Test', html: '<p>Test</p>' }),
|
||||
).rejects.toThrow('SMTP connection refused');
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verify returns true when transporter verifies successfully', async () => {
|
||||
mockTransporter.verify.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verify();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockTransporter.verify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verify returns false when transporter verify fails', async () => {
|
||||
mockTransporter.verify.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
const result = await service.verify();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('onModuleInit creates transporter with env config', () => {
|
||||
process.env['SMTP_HOST'] = 'smtp.example.com';
|
||||
process.env['SMTP_PORT'] = '587';
|
||||
process.env['SMTP_USER'] = 'user@example.com';
|
||||
process.env['SMTP_PASS'] = 'secret';
|
||||
|
||||
const freshService = new EmailService(mockLogger as any);
|
||||
freshService.onModuleInit();
|
||||
|
||||
expect(nodemailer.createTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
auth: { user: 'user@example.com', pass: 'secret' },
|
||||
}),
|
||||
);
|
||||
|
||||
delete process.env['SMTP_HOST'];
|
||||
delete process.env['SMTP_PORT'];
|
||||
delete process.env['SMTP_USER'];
|
||||
delete process.env['SMTP_PASS'];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FcmService } from '../services/fcm.service';
|
||||
|
||||
vi.mock('firebase-admin', () => ({
|
||||
apps: [],
|
||||
initializeApp: vi.fn(),
|
||||
credential: { cert: vi.fn() },
|
||||
messaging: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('FcmService', () => {
|
||||
let service: FcmService;
|
||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
delete process.env['FIREBASE_SERVICE_ACCOUNT'];
|
||||
service = new FcmService(mockLogger as any);
|
||||
});
|
||||
|
||||
it('isAvailable returns false when FIREBASE_SERVICE_ACCOUNT not set', () => {
|
||||
service.onModuleInit();
|
||||
|
||||
expect(service.isAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('send throws when not initialized', async () => {
|
||||
service.onModuleInit();
|
||||
|
||||
await expect(
|
||||
service.send({ token: 'device-token', title: 'Test', body: 'Test body' }),
|
||||
).rejects.toThrow('FCM not initialized — FIREBASE_SERVICE_ACCOUNT not configured');
|
||||
});
|
||||
|
||||
it('onModuleInit logs warning when env not set', () => {
|
||||
service.onModuleInit();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'FIREBASE_SERVICE_ACCOUNT not set — push notifications disabled',
|
||||
'FcmService',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { PrismaNotificationPreferenceRepository } from '../repositories/prisma-notification-preference.repository';
|
||||
|
||||
describe('PrismaNotificationPreferenceRepository', () => {
|
||||
let repository: PrismaNotificationPreferenceRepository;
|
||||
let mockPrisma: {
|
||||
notificationPreference: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockPreference = {
|
||||
id: 'pref-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
eventType: 'user.registered',
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
notificationPreference: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
};
|
||||
repository = new PrismaNotificationPreferenceRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('findByUserId returns preferences for user', async () => {
|
||||
const preferences = [mockPreference, { ...mockPreference, id: 'pref-2', channel: 'PUSH' }];
|
||||
mockPrisma.notificationPreference.findMany.mockResolvedValue(preferences);
|
||||
|
||||
const result = await repository.findByUserId('user-1');
|
||||
|
||||
expect(mockPrisma.notificationPreference.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: 'user-1' } }),
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('isEnabled returns true when no preference exists (default)', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('isEnabled returns false when preference is disabled', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('isEnabled returns true when preference is enabled', async () => {
|
||||
mockPrisma.notificationPreference.findUnique.mockResolvedValue({
|
||||
...mockPreference,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = await repository.isEnabled('user-1', 'EMAIL', 'user.registered');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('upsert creates or updates preference', async () => {
|
||||
mockPrisma.notificationPreference.upsert.mockResolvedValue(mockPreference);
|
||||
|
||||
const result = await repository.upsert('user-1', 'EMAIL', 'user.registered', true);
|
||||
|
||||
expect(mockPrisma.notificationPreference.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId_channel_eventType: { userId: 'user-1', channel: 'EMAIL', eventType: 'user.registered' } },
|
||||
create: { userId: 'user-1', channel: 'EMAIL', eventType: 'user.registered', enabled: true },
|
||||
update: { enabled: true },
|
||||
}),
|
||||
);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { PrismaNotificationRepository } from '../repositories/prisma-notification.repository';
|
||||
|
||||
describe('PrismaNotificationRepository', () => {
|
||||
let repository: PrismaNotificationRepository;
|
||||
let mockPrisma: {
|
||||
notificationLog: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const mockRecord = {
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
status: 'PENDING',
|
||||
errorDetail: null,
|
||||
sentAt: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
notificationLog: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
repository = new PrismaNotificationRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('create creates notification log with PENDING status and returns entity', async () => {
|
||||
mockPrisma.notificationLog.create.mockResolvedValue(mockRecord);
|
||||
|
||||
const result = await repository.create({
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
expect(mockPrisma.notificationLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL',
|
||||
templateKey: 'user.registered',
|
||||
status: 'PENDING',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.id).toBe('notif-1');
|
||||
expect(result.status).toBe('PENDING');
|
||||
expect(result.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('updateStatus updates status to SENT with sentAt date', async () => {
|
||||
mockPrisma.notificationLog.update.mockResolvedValue({ ...mockRecord, status: 'SENT', sentAt: new Date() });
|
||||
|
||||
await repository.updateStatus('notif-1', 'SENT');
|
||||
|
||||
expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'SENT',
|
||||
sentAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updateStatus updates status to FAILED with errorDetail', async () => {
|
||||
mockPrisma.notificationLog.update.mockResolvedValue({
|
||||
...mockRecord,
|
||||
status: 'FAILED',
|
||||
errorDetail: 'SMTP connection refused',
|
||||
});
|
||||
|
||||
await repository.updateStatus('notif-1', 'FAILED', 'SMTP connection refused');
|
||||
|
||||
expect(mockPrisma.notificationLog.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'notif-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'FAILED',
|
||||
errorDetail: 'SMTP connection refused',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('findByUserId returns entities ordered by createdAt desc', async () => {
|
||||
const records = [
|
||||
{ ...mockRecord, id: 'notif-2', createdAt: new Date('2024-01-02') },
|
||||
{ ...mockRecord, id: 'notif-1', createdAt: new Date('2024-01-01') },
|
||||
];
|
||||
mockPrisma.notificationLog.findMany.mockResolvedValue(records);
|
||||
|
||||
const result = await repository.findByUserId('user-1');
|
||||
|
||||
expect(mockPrisma.notificationLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.id).toBe('notif-2');
|
||||
expect(result[1]!.id).toBe('notif-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TemplateService } from '../services/template.service';
|
||||
|
||||
describe('TemplateService', () => {
|
||||
let service: TemplateService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TemplateService();
|
||||
});
|
||||
|
||||
it('render returns rendered subject and body for user.registered template', () => {
|
||||
const result = service.render('user.registered', { phone: '0901234567', role: 'BUYER' });
|
||||
|
||||
expect(result.subject).toBe('Chào mừng bạn đến với GoodGo!');
|
||||
expect(result.body).toContain('0901234567');
|
||||
expect(result.body).toContain('BUYER');
|
||||
});
|
||||
|
||||
it('render returns rendered subject and body for quota.exceeded template', () => {
|
||||
const result = service.render('quota.exceeded', { metric: 'listings', used: 10, limit: 10 });
|
||||
|
||||
expect(result.subject).toBe('Bạn đã đạt giới hạn sử dụng');
|
||||
expect(result.body).toContain('listings');
|
||||
expect(result.body).toContain('10');
|
||||
});
|
||||
|
||||
it('render throws error for unknown template key', () => {
|
||||
expect(() => service.render('does.not.exist', {})).toThrow(
|
||||
'Notification template "does.not.exist" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('hasTemplate returns true for existing template', () => {
|
||||
expect(service.hasTemplate('user.registered')).toBe(true);
|
||||
expect(service.hasTemplate('agent.verified')).toBe(true);
|
||||
expect(service.hasTemplate('quota.exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasTemplate returns false for non-existing template', () => {
|
||||
expect(service.hasTemplate('unknown.template')).toBe(false);
|
||||
});
|
||||
|
||||
it('getTemplateKeys returns all 6 template keys', () => {
|
||||
const keys = service.getTemplateKeys();
|
||||
|
||||
expect(keys).toHaveLength(6);
|
||||
expect(keys).toContain('user.registered');
|
||||
expect(keys).toContain('agent.verified');
|
||||
expect(keys).toContain('listing.approved');
|
||||
expect(keys).toContain('inquiry.received');
|
||||
expect(keys).toContain('quota.exceeded');
|
||||
expect(keys).toContain('password.reset');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NotificationsController } from '../controllers/notifications.controller';
|
||||
|
||||
describe('NotificationsController', () => {
|
||||
let controller: NotificationsController;
|
||||
let mockNotificationRepo: { findByUserId: ReturnType<typeof vi.fn> };
|
||||
let mockPreferenceRepo: { findByUserId: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn> };
|
||||
let mockTemplateService: { getTemplateKeys: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
|
||||
|
||||
const mockNotification = {
|
||||
id: 'notif-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL' as const,
|
||||
templateKey: 'user.registered',
|
||||
subject: 'Welcome',
|
||||
body: '<p>Hello</p>',
|
||||
metadata: null,
|
||||
status: 'SENT' as const,
|
||||
errorDetail: null,
|
||||
sentAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPreference = {
|
||||
id: 'pref-1',
|
||||
userId: 'user-1',
|
||||
channel: 'EMAIL' as const,
|
||||
eventType: 'user.registered',
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockNotificationRepo = { findByUserId: vi.fn() };
|
||||
mockPreferenceRepo = { findByUserId: vi.fn(), upsert: vi.fn() };
|
||||
mockTemplateService = { getTemplateKeys: vi.fn() };
|
||||
|
||||
controller = new NotificationsController(
|
||||
mockNotificationRepo as any,
|
||||
mockPreferenceRepo as any,
|
||||
mockTemplateService as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('getHistory calls notificationRepo.findByUserId with user sub and default limit', async () => {
|
||||
mockNotificationRepo.findByUserId.mockResolvedValue([mockNotification]);
|
||||
|
||||
const result = await controller.getHistory(mockUser as any, undefined);
|
||||
|
||||
expect(mockNotificationRepo.findByUserId).toHaveBeenCalledWith('user-1', 50);
|
||||
expect(result).toEqual([mockNotification]);
|
||||
});
|
||||
|
||||
it('getHistory passes custom limit', async () => {
|
||||
mockNotificationRepo.findByUserId.mockResolvedValue([mockNotification]);
|
||||
|
||||
await controller.getHistory(mockUser as any, 10);
|
||||
|
||||
expect(mockNotificationRepo.findByUserId).toHaveBeenCalledWith('user-1', 10);
|
||||
});
|
||||
|
||||
it('getPreferences calls preferenceRepo.findByUserId', async () => {
|
||||
mockPreferenceRepo.findByUserId.mockResolvedValue([mockPreference]);
|
||||
|
||||
const result = await controller.getPreferences(mockUser as any);
|
||||
|
||||
expect(mockPreferenceRepo.findByUserId).toHaveBeenCalledWith('user-1');
|
||||
expect(result).toEqual([mockPreference]);
|
||||
});
|
||||
|
||||
it('updatePreference calls preferenceRepo.upsert with correct params', async () => {
|
||||
mockPreferenceRepo.upsert.mockResolvedValue({ ...mockPreference, enabled: false });
|
||||
|
||||
const result = await controller.updatePreference(mockUser as any, {
|
||||
channel: 'EMAIL' as any,
|
||||
eventType: 'user.registered',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(mockPreferenceRepo.upsert).toHaveBeenCalledWith('user-1', 'EMAIL', 'user.registered', false);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('getTemplates returns template keys from templateService', async () => {
|
||||
const keys = ['user.registered', 'agent.verified', 'listing.approved', 'inquiry.received', 'quota.exceeded', 'password.reset'];
|
||||
mockTemplateService.getTemplateKeys.mockReturnValue(keys);
|
||||
|
||||
const result = await controller.getTemplates();
|
||||
|
||||
expect(mockTemplateService.getTemplateKeys).toHaveBeenCalled();
|
||||
expect(result).toEqual({ templates: keys });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { MomoService } from '../services/momo.service';
|
||||
|
||||
describe('MomoService', () => {
|
||||
let service: MomoService;
|
||||
const secretKey = 'test-momo-secret-key-32chars!!ab';
|
||||
const partnerCode = 'MOMO_TEST';
|
||||
const accessKey = 'test-access-key';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('MOMO_PARTNER_CODE', partnerCode);
|
||||
vi.stubEnv('MOMO_ACCESS_KEY', accessKey);
|
||||
vi.stubEnv('MOMO_SECRET_KEY', secretKey);
|
||||
service = new MomoService();
|
||||
});
|
||||
|
||||
function buildCallbackData(overrides: Record<string, string> = {}): Record<string, string> {
|
||||
const data: Record<string, string> = {
|
||||
amount: '500000',
|
||||
extraData: '',
|
||||
message: 'Success',
|
||||
orderId: 'order-123',
|
||||
orderInfo: 'Test payment',
|
||||
orderType: 'momo_wallet',
|
||||
partnerCode,
|
||||
payType: 'qr',
|
||||
requestId: 'req-123',
|
||||
responseTime: '1700000000000',
|
||||
resultCode: '0',
|
||||
transId: 'MOMO_TX_123',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const rawSignature = [
|
||||
`accessKey=${accessKey}`,
|
||||
`amount=${data['amount']}`,
|
||||
`extraData=${data['extraData']}`,
|
||||
`message=${data['message']}`,
|
||||
`orderId=${data['orderId']}`,
|
||||
`orderInfo=${data['orderInfo']}`,
|
||||
`orderType=${data['orderType']}`,
|
||||
`partnerCode=${data['partnerCode']}`,
|
||||
`payType=${data['payType']}`,
|
||||
`requestId=${data['requestId']}`,
|
||||
`responseTime=${data['responseTime']}`,
|
||||
`resultCode=${data['resultCode']}`,
|
||||
`transId=${data['transId']}`,
|
||||
].join('&');
|
||||
|
||||
data['signature'] = crypto
|
||||
.createHmac('sha256', secretKey)
|
||||
.update(rawSignature)
|
||||
.digest('hex');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
it('should verify a valid callback with timingSafeEqual', () => {
|
||||
const data = buildCallbackData();
|
||||
const result = service.verifyCallback(data);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(result.orderId).toBe('order-123');
|
||||
expect(result.providerTxId).toBe('MOMO_TX_123');
|
||||
});
|
||||
|
||||
it('should reject an invalid signature', () => {
|
||||
const data = buildCallbackData();
|
||||
// Tamper the signature — replace with a same-length hex string
|
||||
data['signature'] = 'a'.repeat(data['signature']!.length);
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.isSuccess).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an empty signature', () => {
|
||||
const data = buildCallbackData();
|
||||
data['signature'] = '';
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a signature with wrong length', () => {
|
||||
const data = buildCallbackData();
|
||||
data['signature'] = 'abcdef1234'; // too short
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect failed payment (non-zero resultCode) with valid signature', () => {
|
||||
const data = buildCallbackData({ resultCode: '1006' });
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.isSuccess).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ZalopayService } from '../services/zalopay.service';
|
||||
|
||||
describe('ZalopayService', () => {
|
||||
let service: ZalopayService;
|
||||
const key2 = 'test-zalopay-key2-for-callback!!';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('ZALOPAY_APP_ID', '2553');
|
||||
vi.stubEnv('ZALOPAY_KEY1', 'test-zalopay-key1-for-signing!!a');
|
||||
vi.stubEnv('ZALOPAY_KEY2', key2);
|
||||
service = new ZalopayService();
|
||||
});
|
||||
|
||||
function buildCallbackData(
|
||||
dataPayload: Record<string, unknown> = {},
|
||||
tamperMac = false,
|
||||
): Record<string, string> {
|
||||
const payload = {
|
||||
app_id: 2553,
|
||||
app_trans_id: '260408_order-123',
|
||||
zp_trans_id: 'ZLP_TX_456',
|
||||
...dataPayload,
|
||||
};
|
||||
const dataStr = JSON.stringify(payload);
|
||||
let mac = crypto
|
||||
.createHmac('sha256', key2)
|
||||
.update(dataStr)
|
||||
.digest('hex');
|
||||
|
||||
if (tamperMac) {
|
||||
mac = 'a'.repeat(mac.length);
|
||||
}
|
||||
|
||||
return { data: dataStr, mac };
|
||||
}
|
||||
|
||||
it('should verify a valid callback with timingSafeEqual', () => {
|
||||
const data = buildCallbackData();
|
||||
const result = service.verifyCallback(data);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.isSuccess).toBe(true);
|
||||
expect(result.orderId).toBe('260408_order-123');
|
||||
expect(result.providerTxId).toBe('ZLP_TX_456');
|
||||
});
|
||||
|
||||
it('should reject an invalid MAC', () => {
|
||||
const data = buildCallbackData({}, true);
|
||||
const result = service.verifyCallback(data);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.isSuccess).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an empty MAC', () => {
|
||||
const data = buildCallbackData();
|
||||
data['mac'] = '';
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a MAC with wrong length', () => {
|
||||
const data = buildCallbackData();
|
||||
data['mac'] = 'deadbeef'; // too short
|
||||
|
||||
const result = service.verifyCallback(data);
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in data field gracefully', () => {
|
||||
const mac = crypto
|
||||
.createHmac('sha256', key2)
|
||||
.update('not-json')
|
||||
.digest('hex');
|
||||
|
||||
const result = service.verifyCallback({ data: 'not-json', mac });
|
||||
// timingSafeEqual passes but JSON.parse fails
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.isSuccess).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ListingIndexerService } from '../services/listing-indexer.service';
|
||||
|
||||
const mockListing = {
|
||||
id: 'listing-1',
|
||||
status: 'ACTIVE',
|
||||
transactionType: 'SALE',
|
||||
priceVND: BigInt(5000000000),
|
||||
pricePerM2: 50,
|
||||
agentId: 'agent-1',
|
||||
sellerId: 'seller-1',
|
||||
publishedAt: new Date(),
|
||||
viewCount: 10,
|
||||
saveCount: 5,
|
||||
property: {
|
||||
id: 'prop-1',
|
||||
title: 'Test',
|
||||
description: 'Desc',
|
||||
propertyType: 'APARTMENT',
|
||||
areaM2: 80,
|
||||
bedrooms: 2,
|
||||
bathrooms: 1,
|
||||
floors: 1,
|
||||
direction: 'EAST',
|
||||
address: '123 Street',
|
||||
ward: 'Ward 1',
|
||||
district: 'District 1',
|
||||
city: 'HCMC',
|
||||
projectName: null,
|
||||
amenities: ['parking'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ListingIndexerService', () => {
|
||||
let service: ListingIndexerService;
|
||||
let mockPrisma: {
|
||||
listing: { findUnique: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> };
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockSearchRepo: {
|
||||
indexDocument: ReturnType<typeof vi.fn>;
|
||||
removeDocument: ReturnType<typeof vi.fn>;
|
||||
dropCollection: ReturnType<typeof vi.fn>;
|
||||
ensureCollection: ReturnType<typeof vi.fn>;
|
||||
indexDocuments: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
listing: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
mockSearchRepo = {
|
||||
indexDocument: vi.fn(),
|
||||
removeDocument: vi.fn(),
|
||||
dropCollection: vi.fn(),
|
||||
ensureCollection: vi.fn(),
|
||||
indexDocuments: vi.fn(),
|
||||
};
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
service = new ListingIndexerService(
|
||||
mockPrisma as any,
|
||||
mockSearchRepo as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('indexes an active listing when found with coordinates', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
|
||||
mockSearchRepo.indexDocument.mockResolvedValue(undefined);
|
||||
|
||||
await service.indexListing('listing-1');
|
||||
|
||||
expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'listing-1' } }),
|
||||
);
|
||||
expect(mockSearchRepo.indexDocument).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'listing-1', status: 'ACTIVE' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips indexing when listing status is not ACTIVE', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue({ ...mockListing, status: 'INACTIVE' });
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
|
||||
|
||||
await service.indexListing('listing-1');
|
||||
|
||||
expect(mockSearchRepo.indexDocument).not.toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips indexing when listing is not found', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||
|
||||
await service.indexListing('listing-99');
|
||||
|
||||
expect(mockSearchRepo.indexDocument).not.toHaveBeenCalled();
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls searchRepo.removeDocument with the listing id', async () => {
|
||||
mockSearchRepo.removeDocument.mockResolvedValue(undefined);
|
||||
|
||||
await service.removeListing('listing-1');
|
||||
|
||||
expect(mockSearchRepo.removeDocument).toHaveBeenCalledWith('listing-1');
|
||||
});
|
||||
|
||||
it('drops and recreates collection then batches documents during reindexAll', async () => {
|
||||
mockSearchRepo.dropCollection.mockResolvedValue(undefined);
|
||||
mockSearchRepo.ensureCollection.mockResolvedValue(undefined);
|
||||
mockSearchRepo.indexDocuments.mockResolvedValue(undefined);
|
||||
|
||||
const batchListings = [mockListing, { ...mockListing, id: 'listing-2' }];
|
||||
mockPrisma.listing.findMany
|
||||
.mockResolvedValueOnce(batchListings)
|
||||
.mockResolvedValueOnce([]);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{ id: 'prop-1', lat: 10.776, lng: 106.700 }]);
|
||||
|
||||
const result = await service.reindexAll();
|
||||
|
||||
expect(mockSearchRepo.dropCollection).toHaveBeenCalledOnce();
|
||||
expect(mockSearchRepo.ensureCollection).toHaveBeenCalledOnce();
|
||||
expect(mockSearchRepo.indexDocuments).toHaveBeenCalledOnce();
|
||||
expect(result.indexed).toBe(2);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('fetchListingDocumentById returns null when listing is not found', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await service.fetchListingDocumentById('missing-id');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('fetchListingDocumentById returns a complete document with coordinates', async () => {
|
||||
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
|
||||
|
||||
const result = await service.fetchListingDocumentById('listing-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('listing-1');
|
||||
expect(result!.propertyId).toBe('prop-1');
|
||||
expect(result!.title).toBe('Test');
|
||||
expect(result!.priceVND).toBe(5000000000);
|
||||
expect(result!.location).toEqual([10.776, 106.700]);
|
||||
expect(result!.amenities).toEqual(['parking']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { TypesenseSearchRepository } from '../services/typesense-search.repository';
|
||||
import { type ListingDocument, type SearchParams } from '../../domain/repositories/search.repository';
|
||||
|
||||
function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
listingId: 'listing-1',
|
||||
propertyId: 'prop-1',
|
||||
title: 'Test Apartment',
|
||||
description: 'A great place',
|
||||
propertyType: 'APARTMENT',
|
||||
transactionType: 'SALE',
|
||||
priceVND: 5000000000,
|
||||
pricePerM2: 50,
|
||||
areaM2: 80,
|
||||
bedrooms: 2,
|
||||
bathrooms: 1,
|
||||
floors: 1,
|
||||
direction: 'EAST',
|
||||
address: '123 Street',
|
||||
ward: 'Ward 1',
|
||||
district: 'District 1',
|
||||
city: 'HCMC',
|
||||
location: [10.776, 106.700],
|
||||
agentId: 'agent-1',
|
||||
sellerId: 'seller-1',
|
||||
status: 'ACTIVE',
|
||||
publishedAt: 1700000000,
|
||||
viewCount: 10,
|
||||
saveCount: 5,
|
||||
projectName: null,
|
||||
amenities: ['parking'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TypesenseSearchRepository', () => {
|
||||
let repo: TypesenseSearchRepository;
|
||||
let mockClient: {
|
||||
collections: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let collectionOps: {
|
||||
retrieve: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
documents: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let documentOps: {
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
import: ReturnType<typeof vi.fn>;
|
||||
search: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let createFn: ReturnType<typeof vi.fn>;
|
||||
let mockTypesenseClientService: { getClient: ReturnType<typeof vi.fn> };
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
documentOps = {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
import: vi.fn().mockResolvedValue([]),
|
||||
search: vi.fn(),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
collectionOps = {
|
||||
retrieve: vi.fn(),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
documents: vi.fn().mockReturnValue(documentOps),
|
||||
};
|
||||
createFn = vi.fn().mockResolvedValue({});
|
||||
mockClient = {
|
||||
collections: vi.fn().mockImplementation((name?: string) =>
|
||||
name ? collectionOps : { create: createFn },
|
||||
),
|
||||
};
|
||||
mockTypesenseClientService = {
|
||||
getClient: vi.fn().mockReturnValue(mockClient),
|
||||
};
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
repo = new TypesenseSearchRepository(
|
||||
mockTypesenseClientService as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('ensureCollection does not create collection when it already exists', async () => {
|
||||
collectionOps.retrieve.mockResolvedValue({ name: 'listings' });
|
||||
|
||||
await repo.ensureCollection();
|
||||
|
||||
expect(collectionOps.retrieve).toHaveBeenCalled();
|
||||
expect(createFn).not.toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureCollection creates collection when retrieve throws', async () => {
|
||||
collectionOps.retrieve.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await repo.ensureCollection();
|
||||
|
||||
expect(createFn).toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dropCollection deletes the collection', async () => {
|
||||
collectionOps.delete.mockResolvedValue({});
|
||||
|
||||
await repo.dropCollection();
|
||||
|
||||
expect(collectionOps.delete).toHaveBeenCalled();
|
||||
expect(mockLogger.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dropCollection handles missing collection gracefully', async () => {
|
||||
collectionOps.delete.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await expect(repo.dropCollection()).resolves.not.toThrow();
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('indexDocument upserts the document', async () => {
|
||||
const doc = makeDocument();
|
||||
|
||||
await repo.indexDocument(doc);
|
||||
|
||||
expect(mockClient.collections).toHaveBeenCalledWith('listings');
|
||||
expect(collectionOps.documents).toHaveBeenCalled();
|
||||
expect(documentOps.upsert).toHaveBeenCalledWith(doc);
|
||||
});
|
||||
|
||||
it('indexDocuments skips empty array without calling import', async () => {
|
||||
await repo.indexDocuments([]);
|
||||
|
||||
expect(documentOps.import).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeDocument deletes the document by id', async () => {
|
||||
const docOpsWithId = { delete: vi.fn().mockResolvedValue({}) };
|
||||
collectionOps.documents.mockReturnValue(docOpsWithId);
|
||||
|
||||
await repo.removeDocument('listing-1');
|
||||
|
||||
expect(collectionOps.documents).toHaveBeenCalledWith('listing-1');
|
||||
expect(docOpsWithId.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeDocument handles missing document gracefully', async () => {
|
||||
const docOpsWithId = { delete: vi.fn().mockRejectedValue(new Error('Not found')) };
|
||||
collectionOps.documents.mockReturnValue(docOpsWithId);
|
||||
|
||||
await expect(repo.removeDocument('missing-id')).resolves.not.toThrow();
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('search returns formatted results', async () => {
|
||||
const mockHit = { document: makeDocument() };
|
||||
documentOps.search.mockResolvedValue({
|
||||
hits: [mockHit],
|
||||
found: 1,
|
||||
search_time_ms: 3,
|
||||
});
|
||||
|
||||
const params: SearchParams = { query: 'apartment', page: 1, perPage: 20 };
|
||||
const result = await repo.search(params);
|
||||
|
||||
expect(result.hits).toHaveLength(1);
|
||||
expect(result.totalFound).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.perPage).toBe(20);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(result.searchTimeMs).toBe(3);
|
||||
});
|
||||
|
||||
it('search applies geo filter when geoPoint is provided', async () => {
|
||||
documentOps.search.mockResolvedValue({ hits: [], found: 0, search_time_ms: 2 });
|
||||
|
||||
const params: SearchParams = {
|
||||
query: '*',
|
||||
geoPoint: { lat: 10.776, lng: 106.700 },
|
||||
geoRadiusKm: 5,
|
||||
};
|
||||
await repo.search(params);
|
||||
|
||||
const searchCall = documentOps.search.mock.calls[0]![0];
|
||||
expect(searchCall.filter_by).toContain('location:(10.776, 106.7, 5 km)');
|
||||
expect(searchCall.sort_by).toContain('location(10.776, 106.7):asc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { SearchController } from '../controllers/search.controller';
|
||||
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
|
||||
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
|
||||
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
|
||||
|
||||
describe('SearchController', () => {
|
||||
let controller: SearchController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
controller = new SearchController(mockCommandBus as any, mockQueryBus as any);
|
||||
});
|
||||
|
||||
it('search executes SearchPropertiesQuery with correct params', async () => {
|
||||
const mockResult = { hits: [], totalFound: 0, page: 1, perPage: 20, totalPages: 0, searchTimeMs: 1 };
|
||||
mockQueryBus.execute.mockResolvedValue(mockResult);
|
||||
|
||||
const dto = {
|
||||
q: 'căn hộ Quận 7',
|
||||
propertyType: 'APARTMENT',
|
||||
transactionType: 'SALE',
|
||||
priceMin: 1000000000,
|
||||
priceMax: 5000000000,
|
||||
areaMin: 50,
|
||||
areaMax: 200,
|
||||
bedrooms: 2,
|
||||
district: 'Quận 7',
|
||||
city: 'Hồ Chí Minh',
|
||||
sortBy: 'price_asc',
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
};
|
||||
|
||||
const result = await controller.search(dto as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new SearchPropertiesQuery(
|
||||
dto.q,
|
||||
dto.propertyType,
|
||||
dto.transactionType,
|
||||
dto.priceMin,
|
||||
dto.priceMax,
|
||||
dto.areaMin,
|
||||
dto.areaMax,
|
||||
dto.bedrooms,
|
||||
dto.district,
|
||||
dto.city,
|
||||
dto.sortBy,
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
),
|
||||
);
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
|
||||
it('geoSearch executes GeoSearchQuery with correct params', async () => {
|
||||
const mockResult = { hits: [], totalFound: 0, page: 1, perPage: 20, totalPages: 0, searchTimeMs: 2 };
|
||||
mockQueryBus.execute.mockResolvedValue(mockResult);
|
||||
|
||||
const dto = {
|
||||
lat: 10.776,
|
||||
lng: 106.700,
|
||||
radiusKm: 5,
|
||||
propertyType: 'HOUSE',
|
||||
transactionType: 'RENT',
|
||||
priceMin: 5000000,
|
||||
priceMax: 20000000,
|
||||
sortBy: 'distance',
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
};
|
||||
|
||||
const result = await controller.geoSearch(dto as any);
|
||||
|
||||
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
||||
new GeoSearchQuery(
|
||||
dto.lat,
|
||||
dto.lng,
|
||||
dto.radiusKm,
|
||||
dto.propertyType,
|
||||
dto.transactionType,
|
||||
dto.priceMin,
|
||||
dto.priceMax,
|
||||
dto.sortBy,
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
),
|
||||
);
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
|
||||
it('reindex executes ReindexAllCommand', async () => {
|
||||
const mockResult = { indexed: 42, total: 42 };
|
||||
mockCommandBus.execute.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.reindex();
|
||||
|
||||
expect(mockCommandBus.execute).toHaveBeenCalledWith(new ReindexAllCommand());
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user