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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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