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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let mockPrisma: { agent: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
updateStatus: ReturnType<typeof vi.fn>;
|
||||||
|
findByUserId: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockPreferenceRepo: {
|
||||||
|
findByUserId: ReturnType<typeof vi.fn>;
|
||||||
|
isEnabled: ReturnType<typeof vi.fn>;
|
||||||
|
upsert: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockEmailService: { send: ReturnType<typeof vi.fn> };
|
||||||
|
let mockFcmService: { send: ReturnType<typeof vi.fn> };
|
||||||
|
let mockTemplateService: { render: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const notificationEntity = {
|
||||||
|
id: 'notif-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
channel: 'EMAIL' as const,
|
||||||
|
templateKey: 'user.registered',
|
||||||
|
subject: 'Chào mừng!',
|
||||||
|
body: '<p>Chào mừng!</p>',
|
||||||
|
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: '<p>Chào mừng!</p>' }),
|
||||||
|
};
|
||||||
|
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: '<p>Chào mừng!</p>',
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let mockPrisma: { user: { findUnique: ReturnType<typeof vi.fn> } };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>): 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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ReindexAllHandler } from '../commands/reindex-all/reindex-all.handler';
|
||||||
|
|
||||||
|
describe('ReindexAllHandler', () => {
|
||||||
|
let handler: ReindexAllHandler;
|
||||||
|
let mockIndexer: { reindexAll: ReturnType<typeof vi.fn>; indexListing: ReturnType<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>): 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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn>; indexListing: ReturnType<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ListingApprovedEventHandler } from '../event-handlers/listing-approved.handler';
|
||||||
|
|
||||||
|
describe('ListingApprovedEventHandler', () => {
|
||||||
|
let handler: ListingApprovedEventHandler;
|
||||||
|
let mockIndexer: { indexListing: ReturnType<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user