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:
Ho Ngoc Hai
2026-04-21 05:20:39 +07:00
parent f7bb0c0dff
commit ecb217cf5e
3 changed files with 46 additions and 5 deletions

View File

@@ -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',
);
});
});

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 {
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',
);
}
}

View File

@@ -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()