feat(analytics): add Redis 24h cache to neighborhood score endpoint (TEC-3072)
The GET /neighborhoods/:district/score handler was missing Redis caching. Adds NEIGHBORHOOD_SCORE CachePrefix + CacheTTL (24h) and wires CacheService.getOrSet into GetNeighborhoodScoreHandler. Updates handler tests to cover cache behavior. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service';
|
||||
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||
import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||
|
||||
@@ -19,13 +20,21 @@ const sampleScore: NeighborhoodScoreResult = {
|
||||
describe('GetNeighborhoodScoreHandler', () => {
|
||||
let handler: GetNeighborhoodScoreHandler;
|
||||
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
||||
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockService = {
|
||||
getScore: vi.fn(),
|
||||
calculateAndSave: vi.fn(),
|
||||
};
|
||||
handler = new GetNeighborhoodScoreHandler(mockService as any);
|
||||
// Bypass cache: call the loader directly
|
||||
mockCache = {
|
||||
getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()),
|
||||
};
|
||||
handler = new GetNeighborhoodScoreHandler(
|
||||
mockService as any,
|
||||
mockCache as unknown as CacheService,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns cached score when available', async () => {
|
||||
@@ -48,4 +57,17 @@ describe('GetNeighborhoodScoreHandler', () => {
|
||||
expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||
expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||
});
|
||||
|
||||
it('uses CacheService.getOrSet with 24h TTL', async () => {
|
||||
mockService.getScore.mockResolvedValue(sampleScore);
|
||||
|
||||
await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh'));
|
||||
|
||||
expect(mockCache.getOrSet).toHaveBeenCalledWith(
|
||||
expect.stringContaining('neighborhood_score'),
|
||||
expect.any(Function),
|
||||
86400,
|
||||
'neighborhood-score',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared';
|
||||
import {
|
||||
NEIGHBORHOOD_SCORE_SERVICE,
|
||||
type INeighborhoodScoreService,
|
||||
@@ -12,13 +13,27 @@ export class GetNeighborhoodScoreHandler implements IQueryHandler<GetNeighborhoo
|
||||
constructor(
|
||||
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
||||
private readonly scoreService: INeighborhoodScoreService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
||||
// Return cached score if available, otherwise calculate
|
||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||
if (existing) return existing;
|
||||
const cacheKey = CacheService.buildKey(
|
||||
CachePrefix.NEIGHBORHOOD_SCORE,
|
||||
query.district,
|
||||
query.city,
|
||||
);
|
||||
|
||||
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||
return this.cache.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
// Return cached DB score if available, otherwise calculate
|
||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
||||
if (existing) return existing;
|
||||
|
||||
return this.scoreService.calculateAndSave(query.district, query.city);
|
||||
},
|
||||
CacheTTL.NEIGHBORHOOD_SCORE,
|
||||
'neighborhood-score',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ export const CacheTTL = {
|
||||
MARKET_HISTORY: 21600, // 6 hours
|
||||
/** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */
|
||||
VALUATION_LISTING: 86400, // 24 h
|
||||
/** [TEC-3072] Neighborhood score — 24h TTL, POI data changes infrequently */
|
||||
NEIGHBORHOOD_SCORE: 86400, // 24 h
|
||||
} as const;
|
||||
|
||||
export enum CachePrefix {
|
||||
@@ -67,6 +69,8 @@ export enum CachePrefix {
|
||||
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
||||
PRICE_MOVERS = 'cache:analytics:price_movers',
|
||||
MARKET_HISTORY = 'cache:analytics:market_history',
|
||||
/** [TEC-3072] Neighborhood score per district */
|
||||
NEIGHBORHOOD_SCORE = 'cache:analytics:neighborhood_score',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
Reference in New Issue
Block a user