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 { Inject } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
import { CacheService, CachePrefix, CacheTTL, Cacheable } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
type IMarketIndexRepository,
|
type IMarketIndexRepository,
|
||||||
@@ -18,20 +18,20 @@ export interface DistrictStatsDto {
|
|||||||
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
|
export class GetDistrictStatsHandler implements IQueryHandler<GetDistrictStatsQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
@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> {
|
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
|
||||||
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period);
|
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
|
||||||
|
return { city: query.city, period: query.period, districts };
|
||||||
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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
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 { SearchListingsHandler } from '../queries/search-listings/search-listings.handler';
|
||||||
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
|
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
|
||||||
|
|
||||||
describe('SearchListingsHandler', () => {
|
describe('SearchListingsHandler', () => {
|
||||||
let handler: SearchListingsHandler;
|
let handler: SearchListingsHandler;
|
||||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCacheService: { [K in keyof CacheService]: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockListingRepo = {
|
mockListingRepo = {
|
||||||
@@ -17,7 +19,14 @@ describe('SearchListingsHandler', () => {
|
|||||||
findBySellerId: vi.fn(),
|
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 () => {
|
it('searches with all filters', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||||
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
|
import { type ListingSearchItem } from '../../../domain/repositories/listing-read.dto';
|
||||||
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY, type IListingRepository, type PaginatedResult } from '../../../domain/repositories/listing.repository';
|
||||||
import { SearchListingsQuery } from './search-listings.query';
|
import { SearchListingsQuery } from './search-listings.query';
|
||||||
@@ -8,22 +9,45 @@ import { SearchListingsQuery } from './search-listings.query';
|
|||||||
export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery> {
|
export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: SearchListingsQuery): Promise<PaginatedResult<ListingSearchItem>> {
|
async execute(query: SearchListingsQuery): Promise<PaginatedResult<ListingSearchItem>> {
|
||||||
return this.listingRepo.search({
|
const cacheKey = CacheService.buildKey(
|
||||||
status: query.status,
|
CachePrefix.SEARCH,
|
||||||
transactionType: query.transactionType,
|
query.status,
|
||||||
propertyType: query.propertyType,
|
query.transactionType,
|
||||||
city: query.city,
|
query.propertyType,
|
||||||
district: query.district,
|
query.city,
|
||||||
minPrice: query.minPrice,
|
query.district,
|
||||||
maxPrice: query.maxPrice,
|
query.minPrice?.toString(),
|
||||||
minArea: query.minArea,
|
query.maxPrice?.toString(),
|
||||||
maxArea: query.maxArea,
|
query.minArea?.toString(),
|
||||||
bedrooms: query.bedrooms,
|
query.maxArea?.toString(),
|
||||||
page: query.page,
|
query.bedrooms?.toString(),
|
||||||
limit: query.limit,
|
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