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 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 { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler';
|
||||||
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query';
|
||||||
|
|
||||||
@@ -19,13 +20,21 @@ const sampleScore: NeighborhoodScoreResult = {
|
|||||||
describe('GetNeighborhoodScoreHandler', () => {
|
describe('GetNeighborhoodScoreHandler', () => {
|
||||||
let handler: GetNeighborhoodScoreHandler;
|
let handler: GetNeighborhoodScoreHandler;
|
||||||
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: { getOrSet: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockService = {
|
mockService = {
|
||||||
getScore: vi.fn(),
|
getScore: vi.fn(),
|
||||||
calculateAndSave: 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 () => {
|
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.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh');
|
||||||
expect(mockService.calculateAndSave).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 { 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 {
|
import {
|
||||||
NEIGHBORHOOD_SCORE_SERVICE,
|
NEIGHBORHOOD_SCORE_SERVICE,
|
||||||
type INeighborhoodScoreService,
|
type INeighborhoodScoreService,
|
||||||
@@ -12,13 +13,27 @@ export class GetNeighborhoodScoreHandler implements IQueryHandler<GetNeighborhoo
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
@Inject(NEIGHBORHOOD_SCORE_SERVICE)
|
||||||
private readonly scoreService: INeighborhoodScoreService,
|
private readonly scoreService: INeighborhoodScoreService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
async execute(query: GetNeighborhoodScoreQuery): Promise<NeighborhoodScoreResult> {
|
||||||
// Return cached score if available, otherwise calculate
|
const cacheKey = CacheService.buildKey(
|
||||||
const existing = await this.scoreService.getScore(query.district, query.city);
|
CachePrefix.NEIGHBORHOOD_SCORE,
|
||||||
if (existing) return existing;
|
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
|
MARKET_HISTORY: 21600, // 6 hours
|
||||||
/** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */
|
/** AVM valuation estimate per listing — long TTL, model outputs are stable within a day */
|
||||||
VALUATION_LISTING: 86400, // 24 h
|
VALUATION_LISTING: 86400, // 24 h
|
||||||
|
/** [TEC-3072] Neighborhood score — 24h TTL, POI data changes infrequently */
|
||||||
|
NEIGHBORHOOD_SCORE: 86400, // 24 h
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export enum CachePrefix {
|
export enum CachePrefix {
|
||||||
@@ -67,6 +69,8 @@ export enum CachePrefix {
|
|||||||
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
TRENDING_AREAS = 'cache:analytics:trending_areas',
|
||||||
PRICE_MOVERS = 'cache:analytics:price_movers',
|
PRICE_MOVERS = 'cache:analytics:price_movers',
|
||||||
MARKET_HISTORY = 'cache:analytics:market_history',
|
MARKET_HISTORY = 'cache:analytics:market_history',
|
||||||
|
/** [TEC-3072] Neighborhood score per district */
|
||||||
|
NEIGHBORHOOD_SCORE = 'cache:analytics:neighborhood_score',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Reference in New Issue
Block a user