diff --git a/apps/api/src/modules/analytics/domain/__tests__/market-index.entity.spec.ts b/apps/api/src/modules/analytics/domain/__tests__/market-index.entity.spec.ts new file mode 100644 index 0000000..c33e491 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/__tests__/market-index.entity.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/analytics/domain/__tests__/valuation.entity.spec.ts b/apps/api/src/modules/analytics/domain/__tests__/valuation.entity.spec.ts new file mode 100644 index 0000000..821bd66 --- /dev/null +++ b/apps/api/src/modules/analytics/domain/__tests__/valuation.entity.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-market-index.repository.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-market-index.repository.spec.ts new file mode 100644 index 0000000..72280c9 --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-market-index.repository.spec.ts @@ -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 = {}) => ({ + 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; + findMany: ReturnType; + create: ReturnType; + update: ReturnType; + }; + }; + + 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, + }); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-valuation.repository.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-valuation.repository.spec.ts new file mode 100644 index 0000000..27f7a0b --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/prisma-valuation.repository.spec.ts @@ -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 = {}) => ({ + 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; + findMany: ReturnType; + findFirst: ReturnType; + create: ReturnType; + }; + }; + + 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', + }, + }); + }); +}); diff --git a/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts b/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts new file mode 100644 index 0000000..23b99c9 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/__tests__/analytics.controller.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/metrics/infrastructure/__tests__/metrics.service.spec.ts b/apps/api/src/modules/metrics/infrastructure/__tests__/metrics.service.spec.ts new file mode 100644 index 0000000..70b32a4 --- /dev/null +++ b/apps/api/src/modules/metrics/infrastructure/__tests__/metrics.service.spec.ts @@ -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 }; + let mockPaymentsProcessedCounter: { inc: ReturnType }; + let mockActiveSubscriptionsGauge: { set: ReturnType }; + let mockSearchQueriesCounter: { inc: ReturnType }; + let mockRequestDurationHistogram: { observe: ReturnType }; + let mockHttpRequestsCounter: { inc: ReturnType }; + + 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' }), + ); + }); +}); diff --git a/apps/api/src/modules/metrics/presentation/interceptors/__tests__/http-metrics.interceptor.spec.ts b/apps/api/src/modules/metrics/presentation/interceptors/__tests__/http-metrics.interceptor.spec.ts new file mode 100644 index 0000000..5403eb2 --- /dev/null +++ b/apps/api/src/modules/metrics/presentation/interceptors/__tests__/http-metrics.interceptor.spec.ts @@ -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 }; + + beforeEach(() => { + mockMetricsService = { recordHttpRequest: vi.fn() }; + interceptor = new HttpMetricsInterceptor(mockMetricsService as unknown as MetricsService); + }); + + function createContext( + requestOverrides: Record = {}, + responseOverrides: Record = {}, + ): 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((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((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((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((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((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); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/email.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/email.service.spec.ts new file mode 100644 index 0000000..4e252cd --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/email.service.spec.ts @@ -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; verify: ReturnType }; + let mockLogger: { log: ReturnType; error: ReturnType; warn: ReturnType }; + + 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: '

Test body

', + }); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test body

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

Test

' }), + ).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']; + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/fcm.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/fcm.service.spec.ts new file mode 100644 index 0000000..8ffbe6c --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/fcm.service.spec.ts @@ -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; warn: ReturnType; error: ReturnType }; + + 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', + ); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification-preference.repository.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification-preference.repository.spec.ts new file mode 100644 index 0000000..b9c26f2 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification-preference.repository.spec.ts @@ -0,0 +1,92 @@ +import { PrismaNotificationPreferenceRepository } from '../repositories/prisma-notification-preference.repository'; + +describe('PrismaNotificationPreferenceRepository', () => { + let repository: PrismaNotificationPreferenceRepository; + let mockPrisma: { + notificationPreference: { + findMany: ReturnType; + findUnique: ReturnType; + upsert: ReturnType; + }; + }; + + 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); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification.repository.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification.repository.spec.ts new file mode 100644 index 0000000..a2a695d --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/prisma-notification.repository.spec.ts @@ -0,0 +1,120 @@ +import { PrismaNotificationRepository } from '../repositories/prisma-notification.repository'; + +describe('PrismaNotificationRepository', () => { + let repository: PrismaNotificationRepository; + let mockPrisma: { + notificationLog: { + create: ReturnType; + update: ReturnType; + findMany: ReturnType; + }; + }; + + const mockRecord = { + id: 'notif-1', + userId: 'user-1', + channel: 'EMAIL', + templateKey: 'user.registered', + subject: 'Welcome', + body: '

Hello

', + 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: '

Hello

', + 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'); + }); +}); diff --git a/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts new file mode 100644 index 0000000..f92d4bd --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/__tests__/template.service.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/notifications/presentation/__tests__/notifications.controller.spec.ts b/apps/api/src/modules/notifications/presentation/__tests__/notifications.controller.spec.ts new file mode 100644 index 0000000..ac4738d --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/__tests__/notifications.controller.spec.ts @@ -0,0 +1,95 @@ +import { NotificationsController } from '../controllers/notifications.controller'; + +describe('NotificationsController', () => { + let controller: NotificationsController; + let mockNotificationRepo: { findByUserId: ReturnType }; + let mockPreferenceRepo: { findByUserId: ReturnType; upsert: ReturnType }; + let mockTemplateService: { getTemplateKeys: ReturnType }; + + 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: '

Hello

', + 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 }); + }); +}); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts new file mode 100644 index 0000000..1e5e382 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/__tests__/momo.service.spec.ts @@ -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 = {}): Record { + const data: Record = { + 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); + }); +}); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts new file mode 100644 index 0000000..b306b08 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/__tests__/zalopay.service.spec.ts @@ -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 = {}, + tamperMac = false, + ): Record { + 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); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts new file mode 100644 index 0000000..9e1fabe --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/listing-indexer.service.spec.ts @@ -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; findMany: ReturnType }; + $queryRaw: ReturnType; + }; + let mockSearchRepo: { + indexDocument: ReturnType; + removeDocument: ReturnType; + dropCollection: ReturnType; + ensureCollection: ReturnType; + indexDocuments: ReturnType; + }; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + 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']); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts new file mode 100644 index 0000000..a2ec274 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/typesense-search.repository.spec.ts @@ -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 { + 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; + }; + let collectionOps: { + retrieve: ReturnType; + delete: ReturnType; + documents: ReturnType; + }; + let documentOps: { + upsert: ReturnType; + import: ReturnType; + search: ReturnType; + delete: ReturnType; + }; + let createFn: ReturnType; + let mockTypesenseClientService: { getClient: ReturnType }; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/search/presentation/__tests__/search.controller.spec.ts b/apps/api/src/modules/search/presentation/__tests__/search.controller.spec.ts new file mode 100644 index 0000000..d6e5066 --- /dev/null +++ b/apps/api/src/modules/search/presentation/__tests__/search.controller.spec.ts @@ -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 }; + let mockQueryBus: { execute: ReturnType }; + + 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); + }); +});