From 09034a5f9bdcfc5fa78b130f3f4017a2992c6e61 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 04:08:48 +0700 Subject: [PATCH] test: add unit tests for Analytics, Search, and Notifications modules Add 15 test files with 45 test cases covering all untested handlers: - Analytics: track-event, generate-report, update-market-index, get-heatmap, get-price-trend, get-market-report, get-district-stats - Search: reindex-all, sync-listing, search-properties, geo-search, listing-approved event handler - Notifications: send-notification, agent-verified listener, user-registered listener Co-Authored-By: Paperclip --- .../__tests__/generate-report.handler.spec.ts | 61 +++++++ .../get-district-stats.handler.spec.ts | 69 ++++++++ .../__tests__/get-heatmap.handler.spec.ts | 47 ++++++ .../get-market-report.handler.spec.ts | 59 +++++++ .../__tests__/get-price-trend.handler.spec.ts | 48 ++++++ .../__tests__/track-event.handler.spec.ts | 39 +++++ .../update-market-index.handler.spec.ts | 88 ++++++++++ .../__tests__/agent-verified.listener.spec.ts | 67 ++++++++ .../send-notification.handler.spec.ts | 151 ++++++++++++++++++ .../user-registered.listener.spec.ts | 68 ++++++++ .../__tests__/geo-search.handler.spec.ts | 100 ++++++++++++ .../__tests__/reindex-all.handler.spec.ts | 34 ++++ .../search-properties.handler.spec.ts | 108 +++++++++++++ .../__tests__/sync-listing.handler.spec.ts | 26 +++ .../listing-approved.handler.spec.ts | 45 ++++++ 15 files changed, 1010 insertions(+) create mode 100644 apps/api/src/modules/analytics/application/__tests__/generate-report.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts create mode 100644 apps/api/src/modules/notifications/application/__tests__/agent-verified.listener.spec.ts create mode 100644 apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts create mode 100644 apps/api/src/modules/notifications/application/__tests__/user-registered.listener.spec.ts create mode 100644 apps/api/src/modules/search/application/__tests__/geo-search.handler.spec.ts create mode 100644 apps/api/src/modules/search/application/__tests__/reindex-all.handler.spec.ts create mode 100644 apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts create mode 100644 apps/api/src/modules/search/application/__tests__/sync-listing.handler.spec.ts create mode 100644 apps/api/src/modules/search/infrastructure/__tests__/listing-approved.handler.spec.ts diff --git a/apps/api/src/modules/analytics/application/__tests__/generate-report.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/generate-report.handler.spec.ts new file mode 100644 index 0000000..08cd94d --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/generate-report.handler.spec.ts @@ -0,0 +1,61 @@ +import { GenerateReportHandler } from '../commands/generate-report/generate-report.handler'; +import { GenerateReportCommand } from '../commands/generate-report/generate-report.command'; +import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository'; + +describe('GenerateReportHandler', () => { + let handler: GenerateReportHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new GenerateReportHandler(mockRepo as any); + }); + + it('generates a market report for a city and period', async () => { + const reportData: MarketReportResult[] = [ + { + district: 'Quận 1', + city: 'Hồ Chí Minh', + propertyType: 'APARTMENT', + period: '2026-Q1', + medianPrice: '5000000000', + avgPriceM2: 80000000, + totalListings: 150, + daysOnMarket: 45, + inventoryLevel: 3.2, + absorptionRate: 0.65, + yoyChange: 0.08, + }, + ]; + mockRepo.getMarketReport.mockResolvedValue(reportData); + + const command = new GenerateReportCommand('Hồ Chí Minh', '2026-Q1', 'APARTMENT'); + const result = await handler.execute(command); + + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.period).toBe('2026-Q1'); + expect(result.data).toEqual(reportData); + expect(result.generatedAt).toBeDefined(); + expect(mockRepo.getMarketReport).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1', 'APARTMENT'); + }); + + it('generates report without propertyType filter', async () => { + mockRepo.getMarketReport.mockResolvedValue([]); + + const command = new GenerateReportCommand('Hà Nội', '2026-Q1'); + const result = await handler.execute(command); + + expect(result.city).toBe('Hà Nội'); + expect(result.data).toEqual([]); + expect(mockRepo.getMarketReport).toHaveBeenCalledWith('Hà Nội', '2026-Q1', undefined); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts new file mode 100644 index 0000000..898efb5 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-district-stats.handler.spec.ts @@ -0,0 +1,69 @@ +import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler'; +import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query'; +import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository'; + +describe('GetDistrictStatsHandler', () => { + let handler: GetDistrictStatsHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new GetDistrictStatsHandler(mockRepo as any); + }); + + it('returns district statistics', async () => { + const stats: DistrictStatsResult[] = [ + { + district: 'Quận 1', + city: 'Hồ Chí Minh', + propertyType: 'APARTMENT', + medianPrice: '6000000000', + avgPriceM2: 120000000, + totalListings: 200, + daysOnMarket: 30, + inventoryLevel: 2.5, + absorptionRate: 0.8, + yoyChange: 0.12, + }, + { + district: 'Quận 7', + city: 'Hồ Chí Minh', + propertyType: 'APARTMENT', + medianPrice: '4000000000', + avgPriceM2: 80000000, + totalListings: 350, + daysOnMarket: 40, + inventoryLevel: 3.0, + absorptionRate: 0.65, + yoyChange: 0.05, + }, + ]; + mockRepo.getDistrictStats.mockResolvedValue(stats); + + const query = new GetDistrictStatsQuery('Hồ Chí Minh', '2026-Q1'); + const result = await handler.execute(query); + + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.period).toBe('2026-Q1'); + expect(result.districts).toEqual(stats); + expect(mockRepo.getDistrictStats).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1'); + }); + + it('returns empty array when no stats available', async () => { + mockRepo.getDistrictStats.mockResolvedValue([]); + + const query = new GetDistrictStatsQuery('Cần Thơ', '2026-Q1'); + const result = await handler.execute(query); + + expect(result.districts).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts new file mode 100644 index 0000000..14a4212 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-heatmap.handler.spec.ts @@ -0,0 +1,47 @@ +import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler'; +import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query'; +import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository'; + +describe('GetHeatmapHandler', () => { + let handler: GetHeatmapHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new GetHeatmapHandler(mockRepo as any); + }); + + it('returns heatmap data for a city and period', async () => { + const dataPoints: HeatmapDataPoint[] = [ + { district: 'Quận 1', city: 'Hồ Chí Minh', avgPriceM2: 120000000, totalListings: 200, medianPrice: '6000000000' }, + { district: 'Quận 7', city: 'Hồ Chí Minh', avgPriceM2: 80000000, totalListings: 350, medianPrice: '4000000000' }, + ]; + mockRepo.getHeatmap.mockResolvedValue(dataPoints); + + const query = new GetHeatmapQuery('Hồ Chí Minh', '2026-Q1'); + const result = await handler.execute(query); + + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.period).toBe('2026-Q1'); + expect(result.dataPoints).toEqual(dataPoints); + expect(mockRepo.getHeatmap).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1'); + }); + + it('returns empty dataPoints when no data available', async () => { + mockRepo.getHeatmap.mockResolvedValue([]); + + const query = new GetHeatmapQuery('Đà Nẵng', '2025-Q4'); + const result = await handler.execute(query); + + expect(result.dataPoints).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts new file mode 100644 index 0000000..d76015c --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-market-report.handler.spec.ts @@ -0,0 +1,59 @@ +import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler'; +import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query'; +import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository'; + +describe('GetMarketReportHandler', () => { + let handler: GetMarketReportHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new GetMarketReportHandler(mockRepo as any); + }); + + it('returns market report with district data', async () => { + const districts: MarketReportResult[] = [ + { + district: 'Quận 1', + city: 'Hồ Chí Minh', + propertyType: 'APARTMENT', + period: '2026-Q1', + medianPrice: '5000000000', + avgPriceM2: 80000000, + totalListings: 150, + daysOnMarket: 45, + inventoryLevel: 3.2, + absorptionRate: 0.65, + yoyChange: 0.08, + }, + ]; + mockRepo.getMarketReport.mockResolvedValue(districts); + + const query = new GetMarketReportQuery('Hồ Chí Minh', '2026-Q1', 'APARTMENT'); + const result = await handler.execute(query); + + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.period).toBe('2026-Q1'); + expect(result.districts).toEqual(districts); + expect(mockRepo.getMarketReport).toHaveBeenCalledWith('Hồ Chí Minh', '2026-Q1', 'APARTMENT'); + }); + + it('returns report without propertyType filter', async () => { + mockRepo.getMarketReport.mockResolvedValue([]); + + const query = new GetMarketReportQuery('Hà Nội', '2026-Q1'); + const result = await handler.execute(query); + + expect(result.districts).toEqual([]); + expect(mockRepo.getMarketReport).toHaveBeenCalledWith('Hà Nội', '2026-Q1', undefined); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts new file mode 100644 index 0000000..60a5afb --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-price-trend.handler.spec.ts @@ -0,0 +1,48 @@ +import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler'; +import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query'; +import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository'; + +describe('GetPriceTrendHandler', () => { + let handler: GetPriceTrendHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new GetPriceTrendHandler(mockRepo as any); + }); + + it('returns price trend data for a district', async () => { + const trendData: PriceTrendPoint[] = [ + { period: '2025-Q4', medianPrice: '4500000000', avgPriceM2: 75000000, totalListings: 120 }, + { period: '2026-Q1', medianPrice: '5000000000', avgPriceM2: 80000000, totalListings: 150 }, + ]; + mockRepo.getPriceTrend.mockResolvedValue(trendData); + + const query = new GetPriceTrendQuery('Quận 1', 'Hồ Chí Minh', 'APARTMENT', ['2025-Q4', '2026-Q1']); + const result = await handler.execute(query); + + expect(result.district).toBe('Quận 1'); + expect(result.city).toBe('Hồ Chí Minh'); + expect(result.propertyType).toBe('APARTMENT'); + expect(result.trend).toEqual(trendData); + expect(mockRepo.getPriceTrend).toHaveBeenCalledWith('Quận 1', 'Hồ Chí Minh', 'APARTMENT', ['2025-Q4', '2026-Q1']); + }); + + it('returns empty trend when no data available', async () => { + mockRepo.getPriceTrend.mockResolvedValue([]); + + const query = new GetPriceTrendQuery('Quận 9', 'Hồ Chí Minh', 'HOUSE', ['2026-Q1']); + const result = await handler.execute(query); + + expect(result.trend).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts new file mode 100644 index 0000000..c552d44 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/track-event.handler.spec.ts @@ -0,0 +1,39 @@ +import { TrackEventHandler } from '../commands/track-event/track-event.handler'; +import { TrackEventCommand } from '../commands/track-event/track-event.command'; + +describe('TrackEventHandler', () => { + let handler: TrackEventHandler; + + beforeEach(() => { + handler = new TrackEventHandler(); + }); + + it('tracks an event and returns result', async () => { + const command = new TrackEventCommand( + 'page_view', + 'listing-123', + 'LISTING', + { source: 'search' }, + 'user-1', + ); + + const result = await handler.execute(command); + + expect(result.tracked).toBe(true); + expect(result.eventType).toBe('page_view'); + }); + + it('tracks event without userId', async () => { + const command = new TrackEventCommand( + 'search', + 'query-abc', + 'SEARCH', + { keyword: 'Quận 7' }, + ); + + const result = await handler.execute(command); + + expect(result.tracked).toBe(true); + expect(result.eventType).toBe('search'); + }); +}); diff --git a/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts new file mode 100644 index 0000000..e3b58bb --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/update-market-index.handler.spec.ts @@ -0,0 +1,88 @@ +import { UpdateMarketIndexHandler } from '../commands/update-market-index/update-market-index.handler'; +import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command'; +import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository'; +import { MarketIndexEntity } from '../../domain/entities/market-index.entity'; + +function createExistingEntity(): MarketIndexEntity { + return new MarketIndexEntity('idx-1', { + district: 'Quận 1', + city: 'Hồ Chí Minh', + propertyType: 'APARTMENT', + period: '2026-Q1', + medianPrice: BigInt(4000000000), + avgPriceM2: 70000000, + totalListings: 100, + daysOnMarket: 40, + inventoryLevel: 3.0, + absorptionRate: 0.6, + yoyChange: 0.05, + }); +} + +describe('UpdateMarketIndexHandler', () => { + let handler: UpdateMarketIndexHandler; + let mockRepo: { [K in keyof IMarketIndexRepository]: ReturnType }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findByKey: vi.fn(), + save: vi.fn(), + update: vi.fn(), + getMarketReport: vi.fn(), + getHeatmap: vi.fn(), + getPriceTrend: vi.fn(), + getDistrictStats: vi.fn(), + }; + handler = new UpdateMarketIndexHandler(mockRepo as any); + }); + + it('creates a new market index when none exists', async () => { + mockRepo.findByKey.mockResolvedValue(null); + mockRepo.save.mockResolvedValue(undefined); + + const command = new UpdateMarketIndexCommand( + 'Quận 7', 'Hồ Chí Minh', 'APARTMENT', '2026-Q1', + BigInt(5000000000), 80000000, 150, 45, 3.2, 0.65, 0.08, + ); + + const result = await handler.execute(command); + + expect(result.created).toBe(true); + expect(result.id).toBeDefined(); + expect(mockRepo.save).toHaveBeenCalledTimes(1); + expect(mockRepo.update).not.toHaveBeenCalled(); + }); + + it('updates an existing market index', async () => { + const existing = createExistingEntity(); + mockRepo.findByKey.mockResolvedValue(existing); + mockRepo.update.mockResolvedValue(undefined); + + const command = new UpdateMarketIndexCommand( + 'Quận 1', 'Hồ Chí Minh', 'APARTMENT', '2026-Q1', + BigInt(5500000000), 85000000, 160, 42, 3.5, 0.7, 0.1, + ); + + const result = await handler.execute(command); + + expect(result.created).toBe(false); + expect(result.id).toBe('idx-1'); + expect(mockRepo.update).toHaveBeenCalledTimes(1); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + + it('passes correct key parameters to findByKey', async () => { + mockRepo.findByKey.mockResolvedValue(null); + mockRepo.save.mockResolvedValue(undefined); + + const command = new UpdateMarketIndexCommand( + 'Quận 3', 'Hồ Chí Minh', 'HOUSE', '2026-Q2', + BigInt(3000000000), 60000000, 80, 50, 2.5, + ); + + await handler.execute(command); + + expect(mockRepo.findByKey).toHaveBeenCalledWith('Quận 3', 'Hồ Chí Minh', 'HOUSE', '2026-Q2'); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/agent-verified.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/agent-verified.listener.spec.ts new file mode 100644 index 0000000..e251d98 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/agent-verified.listener.spec.ts @@ -0,0 +1,67 @@ +import { AgentVerifiedListener } from '../listeners/agent-verified.listener'; +import { AgentVerifiedEvent } from '@modules/auth/domain/events/agent-verified.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +describe('AgentVerifiedListener', () => { + let listener: AgentVerifiedListener; + let mockCommandBus: { execute: ReturnType }; + let mockPrisma: { agent: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; + mockPrisma = { + agent: { + findUnique: vi.fn(), + }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + listener = new AgentVerifiedListener( + mockCommandBus as any, + mockPrisma as any, + mockLogger as any, + ); + }); + + it('sends email notification when agent has email', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ + id: 'agent-1', + user: { id: 'user-1', email: 'agent@test.com' }, + }); + + const event = new AgentVerifiedEvent('agent-1', 'user-1'); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(SendNotificationCommand), + ); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.channel).toBe('EMAIL'); + expect(cmd.templateKey).toBe('agent.verified'); + expect(cmd.recipientAddress).toBe('agent@test.com'); + }); + + it('skips notification when agent not found', async () => { + mockPrisma.agent.findUnique.mockResolvedValue(null); + + const event = new AgentVerifiedEvent('agent-1', 'user-1'); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('skips notification when agent has no email', async () => { + mockPrisma.agent.findUnique.mockResolvedValue({ + id: 'agent-1', + user: { id: 'user-1', email: null }, + }); + + const event = new AgentVerifiedEvent('agent-1', 'user-1'); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts b/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts new file mode 100644 index 0000000..d69e28a --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/send-notification.handler.spec.ts @@ -0,0 +1,151 @@ +import { SendNotificationHandler } from '../commands/send-notification/send-notification.handler'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +describe('SendNotificationHandler', () => { + let handler: SendNotificationHandler; + let mockNotificationRepo: { + create: ReturnType; + updateStatus: ReturnType; + findByUserId: ReturnType; + }; + let mockPreferenceRepo: { + findByUserId: ReturnType; + isEnabled: ReturnType; + upsert: ReturnType; + }; + let mockEmailService: { send: ReturnType }; + let mockFcmService: { send: ReturnType }; + let mockTemplateService: { render: ReturnType }; + let mockEventBus: { publish: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + const notificationEntity = { + id: 'notif-1', + userId: 'user-1', + channel: 'EMAIL' as const, + templateKey: 'user.registered', + subject: 'Chào mừng!', + body: '

Chào mừng!

', + metadata: null, + status: 'PENDING' as const, + errorDetail: null, + sentAt: null, + createdAt: new Date(), + }; + + beforeEach(() => { + mockNotificationRepo = { + create: vi.fn().mockResolvedValue(notificationEntity), + updateStatus: vi.fn().mockResolvedValue(undefined), + findByUserId: vi.fn(), + }; + mockPreferenceRepo = { + findByUserId: vi.fn(), + isEnabled: vi.fn().mockResolvedValue(true), + upsert: vi.fn(), + }; + mockEmailService = { send: vi.fn().mockResolvedValue({ messageId: 'msg-1' }) }; + mockFcmService = { send: vi.fn().mockResolvedValue('fcm-msg-1') }; + mockTemplateService = { + render: vi.fn().mockReturnValue({ subject: 'Chào mừng!', body: '

Chào mừng!

' }), + }; + mockEventBus = { publish: vi.fn() }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + handler = new SendNotificationHandler( + mockNotificationRepo as any, + mockPreferenceRepo as any, + mockEmailService as any, + mockFcmService as any, + mockTemplateService as any, + mockEventBus as any, + mockLogger as any, + ); + }); + + it('sends email notification successfully', async () => { + const command = new SendNotificationCommand( + 'user-1', 'EMAIL', 'user.registered', { phone: '0901234567', role: 'BUYER' }, 'test@test.com', + ); + + await handler.execute(command); + + expect(mockPreferenceRepo.isEnabled).toHaveBeenCalledWith('user-1', 'EMAIL', 'user.registered'); + expect(mockTemplateService.render).toHaveBeenCalledWith('user.registered', { phone: '0901234567', role: 'BUYER' }); + expect(mockNotificationRepo.create).toHaveBeenCalled(); + expect(mockEmailService.send).toHaveBeenCalledWith({ + to: 'test@test.com', + subject: 'Chào mừng!', + html: '

Chào mừng!

', + }); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT'); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('sends push notification successfully', async () => { + const command = new SendNotificationCommand( + 'user-1', 'PUSH', 'user.registered', { phone: '0901234567', role: 'BUYER' }, 'fcm-token-abc', + ); + + await handler.execute(command); + + expect(mockFcmService.send).toHaveBeenCalledWith({ + token: 'fcm-token-abc', + title: 'Chào mừng!', + body: 'Chào mừng!', // HTML stripped + }); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'SENT'); + }); + + it('skips notification when user preference is disabled', async () => { + mockPreferenceRepo.isEnabled.mockResolvedValue(false); + + const command = new SendNotificationCommand( + 'user-1', 'EMAIL', 'user.registered', {}, 'test@test.com', + ); + + await handler.execute(command); + + expect(mockNotificationRepo.create).not.toHaveBeenCalled(); + expect(mockEmailService.send).not.toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalled(); + }); + + it('logs pending status for unimplemented channels (SMS)', async () => { + const command = new SendNotificationCommand( + 'user-1', 'SMS', 'user.registered', {}, '+84901234567', + ); + + await handler.execute(command); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING'); + expect(mockEmailService.send).not.toHaveBeenCalled(); + expect(mockFcmService.send).not.toHaveBeenCalled(); + }); + + it('logs pending status for unimplemented channels (ZALO_OA)', async () => { + const command = new SendNotificationCommand( + 'user-1', 'ZALO_OA', 'user.registered', {}, 'zalo-id', + ); + + await handler.execute(command); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'PENDING'); + }); + + it('marks notification as FAILED when email send throws', async () => { + mockEmailService.send.mockRejectedValue(new Error('SMTP connection refused')); + + const command = new SendNotificationCommand( + 'user-1', 'EMAIL', 'user.registered', {}, 'test@test.com', + ); + + await handler.execute(command); + + expect(mockNotificationRepo.updateStatus).toHaveBeenCalledWith('notif-1', 'FAILED', 'SMTP connection refused'); + expect(mockLogger.error).toHaveBeenCalled(); + expect(mockEventBus.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/notifications/application/__tests__/user-registered.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/user-registered.listener.spec.ts new file mode 100644 index 0000000..c6edbaf --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/user-registered.listener.spec.ts @@ -0,0 +1,68 @@ +import { UserRegisteredListener } from '../listeners/user-registered.listener'; +import { UserRegisteredEvent } from '@modules/auth/domain/events/user-registered.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +describe('UserRegisteredListener', () => { + let listener: UserRegisteredListener; + let mockCommandBus: { execute: ReturnType }; + let mockPrisma: { user: { findUnique: ReturnType } }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn().mockResolvedValue(undefined) }; + mockPrisma = { + user: { + findUnique: vi.fn(), + }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + listener = new UserRegisteredListener( + mockCommandBus as any, + mockPrisma as any, + mockLogger as any, + ); + }); + + it('sends welcome email when user has email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + email: 'user@test.com', + phone: '0901234567', + }); + + const event = new UserRegisteredEvent('user-1', '0901234567', 'BUYER'); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.any(SendNotificationCommand), + ); + + const cmd = mockCommandBus.execute.mock.calls[0]![0] as SendNotificationCommand; + expect(cmd.userId).toBe('user-1'); + expect(cmd.channel).toBe('EMAIL'); + expect(cmd.templateKey).toBe('user.registered'); + expect(cmd.templateData).toEqual({ phone: '0901234567', role: 'BUYER' }); + expect(cmd.recipientAddress).toBe('user@test.com'); + }); + + it('skips notification when user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const event = new UserRegisteredEvent('user-1', '0901234567', 'BUYER'); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('skips notification when user has no email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + email: null, + phone: '0901234567', + }); + + const event = new UserRegisteredEvent('user-1', '0901234567', 'SELLER'); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/geo-search.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/geo-search.handler.spec.ts new file mode 100644 index 0000000..13ede6e --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/geo-search.handler.spec.ts @@ -0,0 +1,100 @@ +import { GeoSearchHandler } from '../queries/geo-search/geo-search.handler'; +import { GeoSearchQuery } from '../queries/geo-search/geo-search.query'; +import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository'; + +function createMockSearchResult(overrides?: Partial): SearchResult { + return { + hits: [], + totalFound: 0, + page: 1, + perPage: 20, + totalPages: 0, + searchTimeMs: 5, + ...overrides, + }; +} + +describe('GeoSearchHandler', () => { + let handler: GeoSearchHandler; + let mockSearchRepo: { [K in keyof ISearchRepository]: ReturnType }; + + beforeEach(() => { + mockSearchRepo = { + indexDocument: vi.fn(), + indexDocuments: vi.fn(), + removeDocument: vi.fn(), + search: vi.fn(), + ensureCollection: vi.fn(), + dropCollection: vi.fn(), + }; + handler = new GeoSearchHandler(mockSearchRepo as any); + }); + + it('performs geo search with basic parameters', async () => { + const expected = createMockSearchResult({ totalFound: 10 }); + mockSearchRepo.search.mockResolvedValue(expected); + + const query = new GeoSearchQuery(10.7769, 106.7009, 5); + const result = await handler.execute(query); + + expect(result).toEqual(expected); + expect(mockSearchRepo.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: '*', + geoPoint: { lat: 10.7769, lng: 106.7009 }, + geoRadiusKm: 5, + }), + ); + }); + + it('caps radius at 100km', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new GeoSearchQuery(10.7769, 106.7009, 200); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.geoRadiusKm).toBe(100); + }); + + it('applies property and transaction type filters', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new GeoSearchQuery(10.7769, 106.7009, 10, 'APARTMENT', 'RENT'); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('propertyType:=APARTMENT'); + expect(searchCall.filterBy).toContain('transactionType:=RENT'); + }); + + it('applies price range filter', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new GeoSearchQuery(10.7769, 106.7009, 5, undefined, undefined, 1000000000, 5000000000); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('priceVND:[1000000000..5000000000]'); + }); + + it('applies only priceMin filter', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new GeoSearchQuery(10.7769, 106.7009, 5, undefined, undefined, 2000000000); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('priceVND:>=2000000000'); + }); + + it('applies only priceMax filter', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new GeoSearchQuery(10.7769, 106.7009, 5, undefined, undefined, undefined, 3000000000); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('priceVND:<=3000000000'); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/reindex-all.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/reindex-all.handler.spec.ts new file mode 100644 index 0000000..775426b --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/reindex-all.handler.spec.ts @@ -0,0 +1,34 @@ +import { ReindexAllHandler } from '../commands/reindex-all/reindex-all.handler'; + +describe('ReindexAllHandler', () => { + let handler: ReindexAllHandler; + let mockIndexer: { reindexAll: ReturnType; indexListing: ReturnType; removeListing: ReturnType }; + + beforeEach(() => { + mockIndexer = { + reindexAll: vi.fn(), + indexListing: vi.fn(), + removeListing: vi.fn(), + }; + handler = new ReindexAllHandler(mockIndexer as any); + }); + + it('delegates to indexer.reindexAll and returns result', async () => { + mockIndexer.reindexAll.mockResolvedValue({ indexed: 500, total: 500 }); + + const result = await handler.execute(); + + expect(result.indexed).toBe(500); + expect(result.total).toBe(500); + expect(mockIndexer.reindexAll).toHaveBeenCalledTimes(1); + }); + + it('returns zero counts when no listings exist', async () => { + mockIndexer.reindexAll.mockResolvedValue({ indexed: 0, total: 0 }); + + const result = await handler.execute(); + + expect(result.indexed).toBe(0); + expect(result.total).toBe(0); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts new file mode 100644 index 0000000..ae8af48 --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/search-properties.handler.spec.ts @@ -0,0 +1,108 @@ +import { SearchPropertiesHandler } from '../queries/search-properties/search-properties.handler'; +import { SearchPropertiesQuery } from '../queries/search-properties/search-properties.query'; +import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository'; + +function createMockSearchResult(overrides?: Partial): SearchResult { + return { + hits: [], + totalFound: 0, + page: 1, + perPage: 20, + totalPages: 0, + searchTimeMs: 5, + ...overrides, + }; +} + +describe('SearchPropertiesHandler', () => { + let handler: SearchPropertiesHandler; + let mockSearchRepo: { [K in keyof ISearchRepository]: ReturnType }; + + beforeEach(() => { + mockSearchRepo = { + indexDocument: vi.fn(), + indexDocuments: vi.fn(), + removeDocument: vi.fn(), + search: vi.fn(), + ensureCollection: vi.fn(), + dropCollection: vi.fn(), + }; + handler = new SearchPropertiesHandler(mockSearchRepo as any); + }); + + it('searches with basic query', async () => { + const expected = createMockSearchResult({ totalFound: 5 }); + mockSearchRepo.search.mockResolvedValue(expected); + + const query = new SearchPropertiesQuery('Quận 7 căn hộ'); + const result = await handler.execute(query); + + expect(result).toEqual(expected); + expect(mockSearchRepo.search).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'Quận 7 căn hộ', + filterBy: 'status:=ACTIVE', + }), + ); + }); + + it('applies all filters correctly', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery( + 'nhà phố', 'HOUSE', 'SALE', 2000000000, 5000000000, + 50, 200, 3, 'Quận 7', 'Hồ Chí Minh', 'price_asc', 1, 20, + ); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('status:=ACTIVE'); + expect(searchCall.filterBy).toContain('propertyType:=HOUSE'); + expect(searchCall.filterBy).toContain('transactionType:=SALE'); + expect(searchCall.filterBy).toContain('priceVND:[2000000000..5000000000]'); + expect(searchCall.filterBy).toContain('areaM2:[50..200]'); + expect(searchCall.filterBy).toContain('bedrooms:>=3'); + expect(searchCall.filterBy).toContain('district:=Quận 7'); + expect(searchCall.filterBy).toContain('city:=Hồ Chí Minh'); + }); + + it('applies only priceMin when priceMax is undefined', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery(undefined, undefined, undefined, 1000000000); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('priceVND:>=1000000000'); + }); + + it('applies only priceMax when priceMin is undefined', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery(undefined, undefined, undefined, undefined, 5000000000); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('priceVND:<=5000000000'); + }); + + it('applies only areaMin when areaMax is undefined', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery(undefined, undefined, undefined, undefined, undefined, 50); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('areaM2:>=50'); + }); + + it('applies only areaMax when areaMin is undefined', async () => { + mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); + + const query = new SearchPropertiesQuery(undefined, undefined, undefined, undefined, undefined, undefined, 200); + await handler.execute(query); + + const searchCall = mockSearchRepo.search.mock.calls[0]![0]; + expect(searchCall.filterBy).toContain('areaM2:<=200'); + }); +}); diff --git a/apps/api/src/modules/search/application/__tests__/sync-listing.handler.spec.ts b/apps/api/src/modules/search/application/__tests__/sync-listing.handler.spec.ts new file mode 100644 index 0000000..fe52bff --- /dev/null +++ b/apps/api/src/modules/search/application/__tests__/sync-listing.handler.spec.ts @@ -0,0 +1,26 @@ +import { SyncListingHandler } from '../commands/sync-listing/sync-listing.handler'; +import { SyncListingCommand } from '../commands/sync-listing/sync-listing.command'; + +describe('SyncListingHandler', () => { + let handler: SyncListingHandler; + let mockIndexer: { reindexAll: ReturnType; indexListing: ReturnType; removeListing: ReturnType }; + + beforeEach(() => { + mockIndexer = { + reindexAll: vi.fn(), + indexListing: vi.fn(), + removeListing: vi.fn(), + }; + handler = new SyncListingHandler(mockIndexer as any); + }); + + it('indexes a listing by id', async () => { + mockIndexer.indexListing.mockResolvedValue(undefined); + + const command = new SyncListingCommand('listing-123'); + await handler.execute(command); + + expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-123'); + expect(mockIndexer.indexListing).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/modules/search/infrastructure/__tests__/listing-approved.handler.spec.ts b/apps/api/src/modules/search/infrastructure/__tests__/listing-approved.handler.spec.ts new file mode 100644 index 0000000..8c45801 --- /dev/null +++ b/apps/api/src/modules/search/infrastructure/__tests__/listing-approved.handler.spec.ts @@ -0,0 +1,45 @@ +import { ListingApprovedEventHandler } from '../event-handlers/listing-approved.handler'; + +describe('ListingApprovedEventHandler', () => { + let handler: ListingApprovedEventHandler; + let mockIndexer: { indexListing: ReturnType; removeListing: ReturnType }; + let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockIndexer = { + indexListing: vi.fn(), + removeListing: vi.fn(), + }; + mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + handler = new ListingApprovedEventHandler(mockIndexer as any, mockLogger as any); + }); + + it('indexes listing on listing.approved event', async () => { + mockIndexer.indexListing.mockResolvedValue(undefined); + + await handler.handle({ listingId: 'listing-1' }); + + expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-1'); + expect(mockLogger.log).toHaveBeenCalled(); + }); + + it('indexes listing on listing.updated event', async () => { + mockIndexer.indexListing.mockResolvedValue(undefined); + + await handler.handleUpdate({ listingId: 'listing-2' }); + + expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-2'); + }); + + it('removes listing on listing.deactivated event', async () => { + mockIndexer.removeListing.mockResolvedValue(undefined); + + await handler.handleDeactivation({ listingId: 'listing-3' }); + + expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-3'); + }); +});