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