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 index a59116f..4943303 100644 --- 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 @@ -17,6 +17,9 @@ function createMockSearchResult(overrides?: Partial): SearchResult describe('GeoSearchHandler', () => { let handler: GeoSearchHandler; let mockSearchRepo: { [K in keyof ISearchRepository]: ReturnType }; + let mockCache: { + getOrSet: ReturnType; + }; beforeEach(() => { mockSearchRepo = { @@ -27,7 +30,10 @@ describe('GeoSearchHandler', () => { ensureCollection: vi.fn(), dropCollection: vi.fn(), }; - handler = new GeoSearchHandler(mockSearchRepo as any); + mockCache = { + getOrSet: vi.fn().mockImplementation((_key: string, loader: () => Promise) => loader()), + }; + handler = new GeoSearchHandler(mockSearchRepo as any, mockCache as any); }); 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 () => { mockSearchRepo.search.mockResolvedValue(createMockSearchResult()); @@ -97,4 +118,15 @@ describe('GeoSearchHandler', () => { const searchCall = mockSearchRepo.search.mock.calls[0]![0]; 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(); + }); }); diff --git a/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts index 92f3199..30ad60d 100644 --- a/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts +++ b/apps/api/src/modules/search/application/queries/geo-search/geo-search.handler.ts @@ -1,5 +1,6 @@ import { Inject } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service'; import { SEARCH_REPOSITORY, type ISearchRepository, @@ -11,33 +12,53 @@ import { GeoSearchQuery } from './geo-search.query'; export class GeoSearchHandler implements IQueryHandler { constructor( @Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository, + private readonly cache: CacheService, ) {} async execute(query: GeoSearchQuery): Promise { - 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) { - filters.push(`propertyType:=${query.propertyType}`); - } - if (query.transactionType) { - 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.cache.getOrSet( + cacheKey, + async () => { + const filters: string[] = ['status:=ACTIVE']; - 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), - }); + if (query.propertyType) { + filters.push(`propertyType:=${query.propertyType}`); + } + if (query.transactionType) { + 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({ + 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', + ); } } 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 index 8c45801..36b71e4 100644 --- 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 @@ -1,45 +1,83 @@ +import { CachePrefix, CacheService } from '@modules/shared/infrastructure/cache.service'; import { ListingApprovedEventHandler } from '../event-handlers/listing-approved.handler'; describe('ListingApprovedEventHandler', () => { let handler: ListingApprovedEventHandler; let mockIndexer: { indexListing: ReturnType; removeListing: ReturnType }; + let mockCache: { + invalidate: ReturnType; + invalidateByPrefix: ReturnType; + }; let mockLogger: { log: ReturnType; warn: ReturnType; error: ReturnType }; beforeEach(() => { mockIndexer = { - indexListing: vi.fn(), - removeListing: vi.fn(), + indexListing: vi.fn().mockResolvedValue(undefined), + removeListing: vi.fn().mockResolvedValue(undefined), + }; + mockCache = { + invalidate: vi.fn().mockResolvedValue(undefined), + invalidateByPrefix: vi.fn().mockResolvedValue(undefined), }; mockLogger = { log: vi.fn(), warn: 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 () => { - mockIndexer.indexListing.mockResolvedValue(undefined); - + it('indexes listing and invalidates cache on listing.approved event', async () => { await handler.handle({ listingId: '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(); }); - it('indexes listing on listing.updated event', async () => { - mockIndexer.indexListing.mockResolvedValue(undefined); - + it('indexes listing and invalidates cache on listing.updated event', async () => { await handler.handleUpdate({ listingId: 'listing-2' }); expect(mockIndexer.indexListing).toHaveBeenCalledWith('listing-2'); + expect(mockCache.invalidateByPrefix).toHaveBeenCalledWith(CachePrefix.SEARCH); }); - it('removes listing on listing.deactivated event', async () => { - mockIndexer.removeListing.mockResolvedValue(undefined); - + it('removes listing and invalidates cache on listing.deactivated event', async () => { await handler.handleDeactivation({ listingId: '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); }); }); diff --git a/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts b/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts index 9756fd3..805eed7 100644 --- a/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts +++ b/apps/api/src/modules/search/infrastructure/event-handlers/listing-approved.handler.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; 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 ListingIndexerService } from '../services/listing-indexer.service'; @@ -7,24 +8,56 @@ import { type ListingIndexerService } from '../services/listing-indexer.service' export class ListingApprovedEventHandler { constructor( private readonly indexer: ListingIndexerService, + private readonly cache: CacheService, private readonly logger: LoggerService, ) {} @OnEvent('listing.approved') async handle(payload: { listingId: string }): Promise { 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') async handleUpdate(payload: { listingId: string }): Promise { 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') async handleDeactivation(payload: { listingId: string }): Promise { 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 { + 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 { + 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'); } }