test(api): add unit tests for analytics, metrics, notifications, payments, and search modules

New test coverage for infrastructure and presentation layers across
multiple modules including Momo/ZaloPay payment services, Typesense
search repository, listing indexer, and notification handlers.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 23:07:14 +07:00
parent 7fb25eb2b1
commit c9782fd48d
18 changed files with 2097 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import { ListingIndexerService } from '../services/listing-indexer.service';
const mockListing = {
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: BigInt(5000000000),
pricePerM2: 50,
agentId: 'agent-1',
sellerId: 'seller-1',
publishedAt: new Date(),
viewCount: 10,
saveCount: 5,
property: {
id: 'prop-1',
title: 'Test',
description: 'Desc',
propertyType: 'APARTMENT',
areaM2: 80,
bedrooms: 2,
bathrooms: 1,
floors: 1,
direction: 'EAST',
address: '123 Street',
ward: 'Ward 1',
district: 'District 1',
city: 'HCMC',
projectName: null,
amenities: ['parking'],
},
};
describe('ListingIndexerService', () => {
let service: ListingIndexerService;
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> };
$queryRaw: ReturnType<typeof vi.fn>;
};
let mockSearchRepo: {
indexDocument: ReturnType<typeof vi.fn>;
removeDocument: ReturnType<typeof vi.fn>;
dropCollection: ReturnType<typeof vi.fn>;
ensureCollection: ReturnType<typeof vi.fn>;
indexDocuments: ReturnType<typeof vi.fn>;
};
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPrisma = {
listing: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
$queryRaw: vi.fn(),
};
mockSearchRepo = {
indexDocument: vi.fn(),
removeDocument: vi.fn(),
dropCollection: vi.fn(),
ensureCollection: vi.fn(),
indexDocuments: vi.fn(),
};
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
service = new ListingIndexerService(
mockPrisma as any,
mockSearchRepo as any,
mockLogger as any,
);
});
it('indexes an active listing when found with coordinates', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
mockSearchRepo.indexDocument.mockResolvedValue(undefined);
await service.indexListing('listing-1');
expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'listing-1' } }),
);
expect(mockSearchRepo.indexDocument).toHaveBeenCalledWith(
expect.objectContaining({ id: 'listing-1', status: 'ACTIVE' }),
);
});
it('skips indexing when listing status is not ACTIVE', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ ...mockListing, status: 'INACTIVE' });
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
await service.indexListing('listing-1');
expect(mockSearchRepo.indexDocument).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalled();
});
it('skips indexing when listing is not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
await service.indexListing('listing-99');
expect(mockSearchRepo.indexDocument).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalled();
});
it('calls searchRepo.removeDocument with the listing id', async () => {
mockSearchRepo.removeDocument.mockResolvedValue(undefined);
await service.removeListing('listing-1');
expect(mockSearchRepo.removeDocument).toHaveBeenCalledWith('listing-1');
});
it('drops and recreates collection then batches documents during reindexAll', async () => {
mockSearchRepo.dropCollection.mockResolvedValue(undefined);
mockSearchRepo.ensureCollection.mockResolvedValue(undefined);
mockSearchRepo.indexDocuments.mockResolvedValue(undefined);
const batchListings = [mockListing, { ...mockListing, id: 'listing-2' }];
mockPrisma.listing.findMany
.mockResolvedValueOnce(batchListings)
.mockResolvedValueOnce([]);
mockPrisma.$queryRaw.mockResolvedValue([{ id: 'prop-1', lat: 10.776, lng: 106.700 }]);
const result = await service.reindexAll();
expect(mockSearchRepo.dropCollection).toHaveBeenCalledOnce();
expect(mockSearchRepo.ensureCollection).toHaveBeenCalledOnce();
expect(mockSearchRepo.indexDocuments).toHaveBeenCalledOnce();
expect(result.indexed).toBe(2);
expect(result.total).toBe(2);
});
it('fetchListingDocumentById returns null when listing is not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const result = await service.fetchListingDocumentById('missing-id');
expect(result).toBeNull();
});
it('fetchListingDocumentById returns a complete document with coordinates', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(mockListing);
mockPrisma.$queryRaw.mockResolvedValue([{ lat: 10.776, lng: 106.700 }]);
const result = await service.fetchListingDocumentById('listing-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('listing-1');
expect(result!.propertyId).toBe('prop-1');
expect(result!.title).toBe('Test');
expect(result!.priceVND).toBe(5000000000);
expect(result!.location).toEqual([10.776, 106.700]);
expect(result!.amenities).toEqual(['parking']);
});
});

View File

@@ -0,0 +1,195 @@
import { TypesenseSearchRepository } from '../services/typesense-search.repository';
import { type ListingDocument, type SearchParams } from '../../domain/repositories/search.repository';
function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
return {
id: 'listing-1',
listingId: 'listing-1',
propertyId: 'prop-1',
title: 'Test Apartment',
description: 'A great place',
propertyType: 'APARTMENT',
transactionType: 'SALE',
priceVND: 5000000000,
pricePerM2: 50,
areaM2: 80,
bedrooms: 2,
bathrooms: 1,
floors: 1,
direction: 'EAST',
address: '123 Street',
ward: 'Ward 1',
district: 'District 1',
city: 'HCMC',
location: [10.776, 106.700],
agentId: 'agent-1',
sellerId: 'seller-1',
status: 'ACTIVE',
publishedAt: 1700000000,
viewCount: 10,
saveCount: 5,
projectName: null,
amenities: ['parking'],
...overrides,
};
}
describe('TypesenseSearchRepository', () => {
let repo: TypesenseSearchRepository;
let mockClient: {
collections: ReturnType<typeof vi.fn>;
};
let collectionOps: {
retrieve: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
documents: ReturnType<typeof vi.fn>;
};
let documentOps: {
upsert: ReturnType<typeof vi.fn>;
import: ReturnType<typeof vi.fn>;
search: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
let createFn: ReturnType<typeof vi.fn>;
let mockTypesenseClientService: { getClient: ReturnType<typeof vi.fn> };
let mockLogger: {
log: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
documentOps = {
upsert: vi.fn().mockResolvedValue({}),
import: vi.fn().mockResolvedValue([]),
search: vi.fn(),
delete: vi.fn().mockResolvedValue({}),
};
collectionOps = {
retrieve: vi.fn(),
delete: vi.fn().mockResolvedValue({}),
documents: vi.fn().mockReturnValue(documentOps),
};
createFn = vi.fn().mockResolvedValue({});
mockClient = {
collections: vi.fn().mockImplementation((name?: string) =>
name ? collectionOps : { create: createFn },
),
};
mockTypesenseClientService = {
getClient: vi.fn().mockReturnValue(mockClient),
};
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
repo = new TypesenseSearchRepository(
mockTypesenseClientService as any,
mockLogger as any,
);
});
it('ensureCollection does not create collection when it already exists', async () => {
collectionOps.retrieve.mockResolvedValue({ name: 'listings' });
await repo.ensureCollection();
expect(collectionOps.retrieve).toHaveBeenCalled();
expect(createFn).not.toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalled();
});
it('ensureCollection creates collection when retrieve throws', async () => {
collectionOps.retrieve.mockRejectedValue(new Error('Not found'));
await repo.ensureCollection();
expect(createFn).toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalled();
});
it('dropCollection deletes the collection', async () => {
collectionOps.delete.mockResolvedValue({});
await repo.dropCollection();
expect(collectionOps.delete).toHaveBeenCalled();
expect(mockLogger.log).toHaveBeenCalled();
});
it('dropCollection handles missing collection gracefully', async () => {
collectionOps.delete.mockRejectedValue(new Error('Not found'));
await expect(repo.dropCollection()).resolves.not.toThrow();
expect(mockLogger.warn).toHaveBeenCalled();
});
it('indexDocument upserts the document', async () => {
const doc = makeDocument();
await repo.indexDocument(doc);
expect(mockClient.collections).toHaveBeenCalledWith('listings');
expect(collectionOps.documents).toHaveBeenCalled();
expect(documentOps.upsert).toHaveBeenCalledWith(doc);
});
it('indexDocuments skips empty array without calling import', async () => {
await repo.indexDocuments([]);
expect(documentOps.import).not.toHaveBeenCalled();
});
it('removeDocument deletes the document by id', async () => {
const docOpsWithId = { delete: vi.fn().mockResolvedValue({}) };
collectionOps.documents.mockReturnValue(docOpsWithId);
await repo.removeDocument('listing-1');
expect(collectionOps.documents).toHaveBeenCalledWith('listing-1');
expect(docOpsWithId.delete).toHaveBeenCalled();
});
it('removeDocument handles missing document gracefully', async () => {
const docOpsWithId = { delete: vi.fn().mockRejectedValue(new Error('Not found')) };
collectionOps.documents.mockReturnValue(docOpsWithId);
await expect(repo.removeDocument('missing-id')).resolves.not.toThrow();
expect(mockLogger.warn).toHaveBeenCalled();
});
it('search returns formatted results', async () => {
const mockHit = { document: makeDocument() };
documentOps.search.mockResolvedValue({
hits: [mockHit],
found: 1,
search_time_ms: 3,
});
const params: SearchParams = { query: 'apartment', page: 1, perPage: 20 };
const result = await repo.search(params);
expect(result.hits).toHaveLength(1);
expect(result.totalFound).toBe(1);
expect(result.page).toBe(1);
expect(result.perPage).toBe(20);
expect(result.totalPages).toBe(1);
expect(result.searchTimeMs).toBe(3);
});
it('search applies geo filter when geoPoint is provided', async () => {
documentOps.search.mockResolvedValue({ hits: [], found: 0, search_time_ms: 2 });
const params: SearchParams = {
query: '*',
geoPoint: { lat: 10.776, lng: 106.700 },
geoRadiusKm: 5,
};
await repo.search(params);
const searchCall = documentOps.search.mock.calls[0]![0];
expect(searchCall.filter_by).toContain('location:(10.776, 106.7, 5 km)');
expect(searchCall.sort_by).toContain('location(10.776, 106.7):asc');
});
});

View File

@@ -0,0 +1,104 @@
import { SearchController } from '../controllers/search.controller';
import { ReindexAllCommand } from '../../application/commands/reindex-all/reindex-all.command';
import { GeoSearchQuery } from '../../application/queries/geo-search/geo-search.query';
import { SearchPropertiesQuery } from '../../application/queries/search-properties/search-properties.query';
describe('SearchController', () => {
let controller: SearchController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
mockQueryBus = { execute: vi.fn() };
controller = new SearchController(mockCommandBus as any, mockQueryBus as any);
});
it('search executes SearchPropertiesQuery with correct params', async () => {
const mockResult = { hits: [], totalFound: 0, page: 1, perPage: 20, totalPages: 0, searchTimeMs: 1 };
mockQueryBus.execute.mockResolvedValue(mockResult);
const dto = {
q: 'căn hộ Quận 7',
propertyType: 'APARTMENT',
transactionType: 'SALE',
priceMin: 1000000000,
priceMax: 5000000000,
areaMin: 50,
areaMax: 200,
bedrooms: 2,
district: 'Quận 7',
city: 'Hồ Chí Minh',
sortBy: 'price_asc',
page: 1,
perPage: 20,
};
const result = await controller.search(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new SearchPropertiesQuery(
dto.q,
dto.propertyType,
dto.transactionType,
dto.priceMin,
dto.priceMax,
dto.areaMin,
dto.areaMax,
dto.bedrooms,
dto.district,
dto.city,
dto.sortBy,
dto.page,
dto.perPage,
),
);
expect(result).toBe(mockResult);
});
it('geoSearch executes GeoSearchQuery with correct params', async () => {
const mockResult = { hits: [], totalFound: 0, page: 1, perPage: 20, totalPages: 0, searchTimeMs: 2 };
mockQueryBus.execute.mockResolvedValue(mockResult);
const dto = {
lat: 10.776,
lng: 106.700,
radiusKm: 5,
propertyType: 'HOUSE',
transactionType: 'RENT',
priceMin: 5000000,
priceMax: 20000000,
sortBy: 'distance',
page: 1,
perPage: 10,
};
const result = await controller.geoSearch(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
new GeoSearchQuery(
dto.lat,
dto.lng,
dto.radiusKm,
dto.propertyType,
dto.transactionType,
dto.priceMin,
dto.priceMax,
dto.sortBy,
dto.page,
dto.perPage,
),
);
expect(result).toBe(mockResult);
});
it('reindex executes ReindexAllCommand', async () => {
const mockResult = { indexed: 42, total: 42 };
mockCommandBus.execute.mockResolvedValue(mockResult);
const result = await controller.reindex();
expect(mockCommandBus.execute).toHaveBeenCalledWith(new ReindexAllCommand());
expect(result).toBe(mockResult);
});
});