feat(api): add listing search caching and apply @Cacheable decorator

- Add Redis caching to SearchListingsHandler (2 min TTL, query-based key)
- Refactor GetDistrictStatsHandler to use @Cacheable decorator
- Update search-listings test to provide mock CacheService

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 05:14:58 +07:00
parent eaa4925653
commit 4e71036ddd
3 changed files with 61 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { CacheService, CachePrefix, CacheTTL, Cacheable } from '@modules/shared';
import {
MARKET_INDEX_REPOSITORY,
type IMarketIndexRepository,
@@ -18,20 +18,20 @@ export interface DistrictStatsDto {
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
constructor(
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
private readonly cache: CacheService,
private readonly cacheService: CacheService,
) {}
@Cacheable({
prefix: CachePrefix.MARKET_DISTRICT,
ttl: CacheTTL.DISTRICT_STATS,
resource: 'district_stats',
keyFrom: (query: unknown) => {
const q = query as GetDistrictStatsQuery;
return [q.city, q.period];
},
})
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period);
return this.cache.getOrSet(
cacheKey,
async () => {
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
return { city: query.city, period: query.period, districts };
},
CacheTTL.DISTRICT_STATS,
'district_stats',
);
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
return { city: query.city, period: query.period, districts };
}
}

View File

@@ -1,10 +1,12 @@
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { type CacheService } from '@modules/shared';
import { SearchListingsHandler } from '../queries/search-listings/search-listings.handler';
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
describe('SearchListingsHandler', () => {
let handler: SearchListingsHandler;
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
let mockCacheService: { [K in keyof CacheService]: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockListingRepo = {
@@ -17,7 +19,14 @@ describe('SearchListingsHandler', () => {
findBySellerId: vi.fn(),
};
handler = new SearchListingsHandler(mockListingRepo as any);
mockCacheService = {
getOrSet: vi.fn((_key, loader) => loader()),
invalidate: vi.fn(),
invalidateByPrefix: vi.fn(),
onModuleInit: vi.fn(),
};
handler = new SearchListingsHandler(mockListingRepo as any, mockCacheService as any);
});
it('searches with all filters', async () => {

View File

@@ -1,5 +1,6 @@
import { Inject } from '@nestjs/common';
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
import { SearchListingsQuery } from './search-listings.query';
@@ -8,22 +9,45 @@ import { SearchListingsQuery } from './search-listings.query';
export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery> {
constructor(
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
private readonly cacheService: CacheService,
) {}
async execute(query: SearchListingsQuery): Promise<PaginatedResult<ListingSearchItem>> {
return this.listingRepo.search({
status: query.status,
transactionType: query.transactionType,
propertyType: query.propertyType,
city: query.city,
district: query.district,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
minArea: query.minArea,
maxArea: query.maxArea,
bedrooms: query.bedrooms,
page: query.page,
limit: query.limit,
});
const cacheKey = CacheService.buildKey(
CachePrefix.SEARCH,
query.status,
query.transactionType,
query.propertyType,
query.city,
query.district,
query.minPrice?.toString(),
query.maxPrice?.toString(),
query.minArea?.toString(),
query.maxArea?.toString(),
query.bedrooms?.toString(),
String(query.page),
String(query.limit),
);
return this.cacheService.getOrSet(
cacheKey,
async () =>
this.listingRepo.search({
status: query.status,
transactionType: query.transactionType,
propertyType: query.propertyType,
city: query.city,
district: query.district,
minPrice: query.minPrice,
maxPrice: query.maxPrice,
minArea: query.minArea,
maxArea: query.maxArea,
bedrooms: query.bedrooms,
page: query.page,
limit: query.limit,
}),
CacheTTL.SEARCH_RESULTS,
'listing_search',
);
}
}