feat(search): enhance geo-search and listing-approved handlers
Improve geo-search handler with better query processing and update listing-approved event handler with enhanced indexing logic. Tests updated accordingly. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -17,6 +17,9 @@ function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult
|
|||||||
describe('GeoSearchHandler', () => {
|
describe('GeoSearchHandler', () => {
|
||||||
let handler: GeoSearchHandler;
|
let handler: GeoSearchHandler;
|
||||||
let mockSearchRepo: { [K in keyof ISearchRepository]: ReturnType<typeof vi.fn> };
|
let mockSearchRepo: { [K in keyof ISearchRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: {
|
||||||
|
getOrSet: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSearchRepo = {
|
mockSearchRepo = {
|
||||||
@@ -27,7 +30,10 @@ describe('GeoSearchHandler', () => {
|
|||||||
ensureCollection: vi.fn(),
|
ensureCollection: vi.fn(),
|
||||||
dropCollection: vi.fn(),
|
dropCollection: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new GeoSearchHandler(mockSearchRepo as any);
|
mockCache = {
|
||||||
|
getOrSet: vi.fn().mockImplementation((_key: string, loader: () => Promise<unknown>) => loader()),
|
||||||
|
};
|
||||||
|
handler = new GeoSearchHandler(mockSearchRepo as any, mockCache as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('performs geo search with basic parameters', async () => {
|
it('performs geo search with basic parameters', async () => {
|
||||||
@@ -47,6 +53,21 @@ describe('GeoSearchHandler', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses cache with correct TTL and resource label', async () => {
|
||||||
|
const expected = createMockSearchResult({ totalFound: 5 });
|
||||||
|
mockSearchRepo.search.mockResolvedValue(expected);
|
||||||
|
|
||||||
|
const query = new GeoSearchQuery(10.7769, 106.7009, 5);
|
||||||
|
await handler.execute(query);
|
||||||
|
|
||||||
|
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('cache:geo_search:'),
|
||||||
|
expect.any(Function),
|
||||||
|
60,
|
||||||
|
'geo_search',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('caps radius at 100km', async () => {
|
it('caps radius at 100km', async () => {
|
||||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||||
|
|
||||||
@@ -97,4 +118,15 @@ describe('GeoSearchHandler', () => {
|
|||||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||||
expect(searchCall.filterBy).toContain('priceVND:<=3000000000');
|
expect(searchCall.filterBy).toContain('priceVND:<=3000000000');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns cached result without calling search repo', async () => {
|
||||||
|
const cachedResult = createMockSearchResult({ totalFound: 42 });
|
||||||
|
mockCache.getOrSet.mockResolvedValue(cachedResult);
|
||||||
|
|
||||||
|
const query = new GeoSearchQuery(10.7769, 106.7009, 5);
|
||||||
|
const result = await handler.execute(query);
|
||||||
|
|
||||||
|
expect(result).toEqual(cachedResult);
|
||||||
|
expect(mockSearchRepo.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import {
|
import {
|
||||||
SEARCH_REPOSITORY,
|
SEARCH_REPOSITORY,
|
||||||
type ISearchRepository,
|
type ISearchRepository,
|
||||||
@@ -11,33 +12,53 @@ import { GeoSearchQuery } from './geo-search.query';
|
|||||||
export class GeoSearchHandler implements IQueryHandler<GeoSearchQuery> {
|
export class GeoSearchHandler implements IQueryHandler<GeoSearchQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
|
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GeoSearchQuery): Promise<SearchResult> {
|
async execute(query: GeoSearchQuery): Promise<SearchResult> {
|
||||||
const filters: string[] = ['status:=ACTIVE'];
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.GEO_SEARCH,
|
||||||
|
`${query.lat}_${query.lng}_${query.radiusKm}`,
|
||||||
|
query.propertyType,
|
||||||
|
query.transactionType,
|
||||||
|
query.priceMin,
|
||||||
|
query.priceMax,
|
||||||
|
query.sortBy,
|
||||||
|
query.page,
|
||||||
|
query.perPage,
|
||||||
|
);
|
||||||
|
|
||||||
if (query.propertyType) {
|
return this.cache.getOrSet(
|
||||||
filters.push(`propertyType:=${query.propertyType}`);
|
cacheKey,
|
||||||
}
|
async () => {
|
||||||
if (query.transactionType) {
|
const filters: string[] = ['status:=ACTIVE'];
|
||||||
filters.push(`transactionType:=${query.transactionType}`);
|
|
||||||
}
|
|
||||||
if (query.priceMin !== undefined && query.priceMax !== undefined) {
|
|
||||||
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
|
|
||||||
} else if (query.priceMin !== undefined) {
|
|
||||||
filters.push(`priceVND:>=${query.priceMin}`);
|
|
||||||
} else if (query.priceMax !== undefined) {
|
|
||||||
filters.push(`priceVND:<=${query.priceMax}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.searchRepo.search({
|
if (query.propertyType) {
|
||||||
query: '*',
|
filters.push(`propertyType:=${query.propertyType}`);
|
||||||
filterBy: filters.join(' && '),
|
}
|
||||||
sortBy: query.sortBy,
|
if (query.transactionType) {
|
||||||
page: query.page,
|
filters.push(`transactionType:=${query.transactionType}`);
|
||||||
perPage: query.perPage,
|
}
|
||||||
geoPoint: { lat: query.lat, lng: query.lng },
|
if (query.priceMin !== undefined && query.priceMax !== undefined) {
|
||||||
geoRadiusKm: Math.min(query.radiusKm, 100),
|
filters.push(`priceVND:[${query.priceMin}..${query.priceMax}]`);
|
||||||
});
|
} else if (query.priceMin !== undefined) {
|
||||||
|
filters.push(`priceVND:>=${query.priceMin}`);
|
||||||
|
} else if (query.priceMax !== undefined) {
|
||||||
|
filters.push(`priceVND:<=${query.priceMax}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.searchRepo.search({
|
||||||
|
query: '*',
|
||||||
|
filterBy: filters.join(' && '),
|
||||||
|
sortBy: query.sortBy,
|
||||||
|
page: query.page,
|
||||||
|
perPage: query.perPage,
|
||||||
|
geoPoint: { lat: query.lat, lng: query.lng },
|
||||||
|
geoRadiusKm: Math.min(query.radiusKm, 100),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
CacheTTL.SEARCH_RESULTS,
|
||||||
|
'geo_search',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,83 @@
|
|||||||
|
import { CachePrefix, CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { ListingApprovedEventHandler } from '../event-handlers/listing-approved.handler';
|
import { ListingApprovedEventHandler } from '../event-handlers/listing-approved.handler';
|
||||||
|
|
||||||
describe('ListingApprovedEventHandler', () => {
|
describe('ListingApprovedEventHandler', () => {
|
||||||
let handler: ListingApprovedEventHandler;
|
let handler: ListingApprovedEventHandler;
|
||||||
let mockIndexer: { indexListing: ReturnType<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
let mockIndexer: { indexListing: ReturnType<typeof vi.fn>; removeListing: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: {
|
||||||
|
invalidate: ReturnType<typeof vi.fn>;
|
||||||
|
invalidateByPrefix: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockIndexer = {
|
mockIndexer = {
|
||||||
indexListing: vi.fn(),
|
indexListing: vi.fn().mockResolvedValue(undefined),
|
||||||
removeListing: vi.fn(),
|
removeListing: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
mockCache = {
|
||||||
|
invalidate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockLogger = {
|
mockLogger = {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new ListingApprovedEventHandler(mockIndexer as any, mockLogger as any);
|
handler = new ListingApprovedEventHandler(mockIndexer as any, mockCache as any, mockLogger as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('indexes listing on listing.approved event', async () => {
|
it('indexes listing and invalidates cache on listing.approved event', async () => {
|
||||||
mockIndexer.indexListing.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await handler.handle({ listingId: 'listing-1' });
|
await handler.handle({ listingId: 'listing-1' });
|
||||||
|
|
||||||
expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-1');
|
expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-1');
|
||||||
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
|
CacheService.buildKey(CachePrefix.LISTING, 'listing-1'),
|
||||||
|
);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.GEO_SEARCH);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.MARKET_DISTRICT);
|
||||||
expect(mockLogger.log).toHaveBeenCalled();
|
expect(mockLogger.log).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('indexes listing on listing.updated event', async () => {
|
it('indexes listing and invalidates cache on listing.updated event', async () => {
|
||||||
mockIndexer.indexListing.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await handler.handleUpdate({ listingId: 'listing-2' });
|
await handler.handleUpdate({ listingId: 'listing-2' });
|
||||||
|
|
||||||
expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-2');
|
expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-2');
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes listing on listing.deactivated event', async () => {
|
it('removes listing and invalidates cache on listing.deactivated event', async () => {
|
||||||
mockIndexer.removeListing.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await handler.handleDeactivation({ listingId: 'listing-3' });
|
await handler.handleDeactivation({ listingId: 'listing-3' });
|
||||||
|
|
||||||
expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-3');
|
expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-3');
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes listing and invalidates cache on listing.sold event', async () => {
|
||||||
|
await handler.handleSold({ listingId: 'listing-4' });
|
||||||
|
|
||||||
|
expect(mockIndexer.removeListing).toHaveBeenCalledWith('listing-4');
|
||||||
|
expect(mockCache.invalidate).toHaveBeenCalledWith(
|
||||||
|
CacheService.buildKey(CachePrefix.LISTING, 'listing-4'),
|
||||||
|
);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.GEO_SEARCH);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.MARKET_DISTRICT);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.MARKET_REPORT);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.MARKET_HEATMAP);
|
||||||
|
expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.MARKET_TREND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates all analytics cache prefixes on listing events', async () => {
|
||||||
|
await handler.handle({ listingId: 'listing-5' });
|
||||||
|
|
||||||
|
const prefixCalls = mockCache.invalidateByPrefix.mock.calls.map(
|
||||||
|
(c: [string]) => c[0],
|
||||||
|
);
|
||||||
|
expect(prefixCalls).toContain(CachePrefix.MARKET_DISTRICT);
|
||||||
|
expect(prefixCalls).toContain(CachePrefix.MARKET_REPORT);
|
||||||
|
expect(prefixCalls).toContain(CachePrefix.MARKET_HEATMAP);
|
||||||
|
expect(prefixCalls).toContain(CachePrefix.MARKET_TREND);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
|
||||||
import { type ListingIndexerService } from '../services/listing-indexer.service';
|
import { type ListingIndexerService } from '../services/listing-indexer.service';
|
||||||
|
|
||||||
@@ -7,24 +8,56 @@ import { type ListingIndexerService } from '../services/listing-indexer.service'
|
|||||||
export class ListingApprovedEventHandler {
|
export class ListingApprovedEventHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly indexer: ListingIndexerService,
|
private readonly indexer: ListingIndexerService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
private readonly logger: LoggerService,
|
private readonly logger: LoggerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent('listing.approved')
|
@OnEvent('listing.approved')
|
||||||
async handle(payload: { listingId: string }): Promise<void> {
|
async handle(payload: { listingId: string }): Promise<void> {
|
||||||
this.logger.log(`Handling listing.approved for ${payload.listingId}`, 'ListingApprovedHandler');
|
this.logger.log(`Handling listing.approved for ${payload.listingId}`, 'ListingApprovedHandler');
|
||||||
await this.indexer.indexListing(payload.listingId);
|
await Promise.all([
|
||||||
|
this.indexer.indexListing(payload.listingId),
|
||||||
|
this.invalidateSearchAndAnalyticsCache(payload.listingId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('listing.updated')
|
@OnEvent('listing.updated')
|
||||||
async handleUpdate(payload: { listingId: string }): Promise<void> {
|
async handleUpdate(payload: { listingId: string }): Promise<void> {
|
||||||
this.logger.log(`Handling listing.updated for ${payload.listingId}`, 'ListingApprovedHandler');
|
this.logger.log(`Handling listing.updated for ${payload.listingId}`, 'ListingApprovedHandler');
|
||||||
await this.indexer.indexListing(payload.listingId);
|
await Promise.all([
|
||||||
|
this.indexer.indexListing(payload.listingId),
|
||||||
|
this.invalidateSearchAndAnalyticsCache(payload.listingId),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('listing.deactivated')
|
@OnEvent('listing.deactivated')
|
||||||
async handleDeactivation(payload: { listingId: string }): Promise<void> {
|
async handleDeactivation(payload: { listingId: string }): Promise<void> {
|
||||||
this.logger.log(`Handling listing.deactivated for ${payload.listingId}`, 'ListingApprovedHandler');
|
this.logger.log(`Handling listing.deactivated for ${payload.listingId}`, 'ListingApprovedHandler');
|
||||||
await this.indexer.removeListing(payload.listingId);
|
await Promise.all([
|
||||||
|
this.indexer.removeListing(payload.listingId),
|
||||||
|
this.invalidateSearchAndAnalyticsCache(payload.listingId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent('listing.sold')
|
||||||
|
async handleSold(payload: { listingId: string }): Promise<void> {
|
||||||
|
this.logger.log(`Handling listing.sold for ${payload.listingId}`, 'ListingApprovedHandler');
|
||||||
|
await Promise.all([
|
||||||
|
this.indexer.removeListing(payload.listingId),
|
||||||
|
this.invalidateSearchAndAnalyticsCache(payload.listingId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async invalidateSearchAndAnalyticsCache(listingId: string): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, listingId)),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.GEO_SEARCH),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_HEATMAP),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_TREND),
|
||||||
|
]);
|
||||||
|
this.logger.log(`Cache invalidated for listing ${listingId}`, 'ListingApprovedHandler');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user