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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user