feat(cache): implement Redis caching layer for hot-read endpoints
Add cache-aside pattern for listing detail, search results, market analytics (4 endpoints), and user profile queries. Cache invalidation on all write mutations. Prometheus cache_hit_total/cache_miss_total metrics with resource labels. - CacheService: getOrSet, invalidate, invalidateByPrefix (SCAN-based) - TTLs: listing 5m, search 1m, market 30m, profile 10m - All 230 tests passing (13 new cache tests + 6 updated handler tests) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler';
|
import { GetDistrictStatsHandler } from '../queries/get-district-stats/get-district-stats.handler';
|
||||||
import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query';
|
import { GetDistrictStatsQuery } from '../queries/get-district-stats/get-district-stats.query';
|
||||||
import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository';
|
import { type IMarketIndexRepository, type DistrictStatsResult } from '../../domain/repositories/market-index.repository';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
describe('GetDistrictStatsHandler', () => {
|
describe('GetDistrictStatsHandler', () => {
|
||||||
let handler: GetDistrictStatsHandler;
|
let handler: GetDistrictStatsHandler;
|
||||||
@@ -17,7 +18,8 @@ describe('GetDistrictStatsHandler', () => {
|
|||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new GetDistrictStatsHandler(mockRepo as any);
|
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||||
|
handler = new GetDistrictStatsHandler(mockRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns district statistics', async () => {
|
it('returns district statistics', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler';
|
import { GetHeatmapHandler } from '../queries/get-heatmap/get-heatmap.handler';
|
||||||
import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query';
|
import { GetHeatmapQuery } from '../queries/get-heatmap/get-heatmap.query';
|
||||||
import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository';
|
import { type IMarketIndexRepository, type HeatmapDataPoint } from '../../domain/repositories/market-index.repository';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
describe('GetHeatmapHandler', () => {
|
describe('GetHeatmapHandler', () => {
|
||||||
let handler: GetHeatmapHandler;
|
let handler: GetHeatmapHandler;
|
||||||
@@ -17,7 +18,8 @@ describe('GetHeatmapHandler', () => {
|
|||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new GetHeatmapHandler(mockRepo as any);
|
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||||
|
handler = new GetHeatmapHandler(mockRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns heatmap data for a city and period', async () => {
|
it('returns heatmap data for a city and period', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler';
|
import { GetMarketReportHandler } from '../queries/get-market-report/get-market-report.handler';
|
||||||
import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query';
|
import { GetMarketReportQuery } from '../queries/get-market-report/get-market-report.query';
|
||||||
import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository';
|
import { type IMarketIndexRepository, type MarketReportResult } from '../../domain/repositories/market-index.repository';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
describe('GetMarketReportHandler', () => {
|
describe('GetMarketReportHandler', () => {
|
||||||
let handler: GetMarketReportHandler;
|
let handler: GetMarketReportHandler;
|
||||||
@@ -17,7 +18,8 @@ describe('GetMarketReportHandler', () => {
|
|||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new GetMarketReportHandler(mockRepo as any);
|
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||||
|
handler = new GetMarketReportHandler(mockRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns market report with district data', async () => {
|
it('returns market report with district data', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler';
|
import { GetPriceTrendHandler } from '../queries/get-price-trend/get-price-trend.handler';
|
||||||
import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query';
|
import { GetPriceTrendQuery } from '../queries/get-price-trend/get-price-trend.query';
|
||||||
import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository';
|
import { type IMarketIndexRepository, type PriceTrendPoint } from '../../domain/repositories/market-index.repository';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
describe('GetPriceTrendHandler', () => {
|
describe('GetPriceTrendHandler', () => {
|
||||||
let handler: GetPriceTrendHandler;
|
let handler: GetPriceTrendHandler;
|
||||||
@@ -17,7 +18,8 @@ describe('GetPriceTrendHandler', () => {
|
|||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new GetPriceTrendHandler(mockRepo as any);
|
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||||
|
handler = new GetPriceTrendHandler(mockRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns price trend data for a district', async () => {
|
it('returns price trend data for a district', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { UpdateMarketIndexHandler } from '../commands/update-market-index/update
|
|||||||
import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command';
|
import { UpdateMarketIndexCommand } from '../commands/update-market-index/update-market-index.command';
|
||||||
import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository';
|
import { type IMarketIndexRepository } from '../../domain/repositories/market-index.repository';
|
||||||
import { MarketIndexEntity } from '../../domain/entities/market-index.entity';
|
import { MarketIndexEntity } from '../../domain/entities/market-index.entity';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
function createExistingEntity(): MarketIndexEntity {
|
function createExistingEntity(): MarketIndexEntity {
|
||||||
return new MarketIndexEntity('idx-1', {
|
return new MarketIndexEntity('idx-1', {
|
||||||
@@ -34,7 +35,8 @@ describe('UpdateMarketIndexHandler', () => {
|
|||||||
getPriceTrend: vi.fn(),
|
getPriceTrend: vi.fn(),
|
||||||
getDistrictStats: vi.fn(),
|
getDistrictStats: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new UpdateMarketIndexHandler(mockRepo as any);
|
const mockCache = { invalidateByPrefix: vi.fn() } as unknown as CacheService;
|
||||||
|
handler = new UpdateMarketIndexHandler(mockRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new market index when none exists', async () => {
|
it('creates a new market index when none exists', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { UpdateMarketIndexCommand } from './update-market-index.command';
|
import { UpdateMarketIndexCommand } from './update-market-index.command';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
@@ -16,6 +17,7 @@ export interface UpdateMarketIndexResult {
|
|||||||
export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketIndexCommand> {
|
export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketIndexCommand> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UpdateMarketIndexCommand): Promise<UpdateMarketIndexResult> {
|
async execute(command: UpdateMarketIndexCommand): Promise<UpdateMarketIndexResult> {
|
||||||
@@ -37,6 +39,7 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
|
|||||||
command.yoyChange,
|
command.yoyChange,
|
||||||
);
|
);
|
||||||
await this.marketIndexRepo.update(existing);
|
await this.marketIndexRepo.update(existing);
|
||||||
|
await this.invalidateMarketCaches();
|
||||||
return { id: existing.id, created: false };
|
return { id: existing.id, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +60,18 @@ export class UpdateMarketIndexHandler implements ICommandHandler<UpdateMarketInd
|
|||||||
);
|
);
|
||||||
|
|
||||||
await this.marketIndexRepo.save(entity);
|
await this.marketIndexRepo.save(entity);
|
||||||
|
|
||||||
|
await this.invalidateMarketCaches();
|
||||||
|
|
||||||
return { id, created: true };
|
return { id, created: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async invalidateMarketCaches(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_REPORT),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_TREND),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_HEATMAP),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.MARKET_DISTRICT),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetDistrictStatsQuery } from './get-district-stats.query';
|
import { GetDistrictStatsQuery } from './get-district-stats.query';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
@@ -17,15 +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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
|
async execute(query: GetDistrictStatsQuery): Promise<DistrictStatsDto> {
|
||||||
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
|
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_DISTRICT, query.city, query.period);
|
||||||
|
|
||||||
return {
|
return this.cache.getOrSet(
|
||||||
city: query.city,
|
cacheKey,
|
||||||
period: query.period,
|
async () => {
|
||||||
districts,
|
const districts = await this.marketIndexRepo.getDistrictStats(query.city, query.period);
|
||||||
};
|
return { city: query.city, period: query.period, districts };
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'district_stats',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetHeatmapQuery } from './get-heatmap.query';
|
import { GetHeatmapQuery } from './get-heatmap.query';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
@@ -17,15 +18,20 @@ export interface HeatmapDto {
|
|||||||
export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
|
export class GetHeatmapHandler implements IQueryHandler<GetHeatmapQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
|
async execute(query: GetHeatmapQuery): Promise<HeatmapDto> {
|
||||||
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
|
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_HEATMAP, query.city, query.period);
|
||||||
|
|
||||||
return {
|
return this.cache.getOrSet(
|
||||||
city: query.city,
|
cacheKey,
|
||||||
period: query.period,
|
async () => {
|
||||||
dataPoints,
|
const dataPoints = await this.marketIndexRepo.getHeatmap(query.city, query.period);
|
||||||
};
|
return { city: query.city, period: query.period, dataPoints };
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'heatmap',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetMarketReportQuery } from './get-market-report.query';
|
import { GetMarketReportQuery } from './get-market-report.query';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
@@ -17,19 +18,24 @@ export interface MarketReportDto {
|
|||||||
export class GetMarketReportHandler implements IQueryHandler<GetMarketReportQuery> {
|
export class GetMarketReportHandler implements IQueryHandler<GetMarketReportQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetMarketReportQuery): Promise<MarketReportDto> {
|
async execute(query: GetMarketReportQuery): Promise<MarketReportDto> {
|
||||||
const districts = await this.marketIndexRepo.getMarketReport(
|
const cacheKey = CacheService.buildKey(CachePrefix.MARKET_REPORT, query.city, query.period, query.propertyType);
|
||||||
query.city,
|
|
||||||
query.period,
|
|
||||||
query.propertyType,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return this.cache.getOrSet(
|
||||||
city: query.city,
|
cacheKey,
|
||||||
period: query.period,
|
async () => {
|
||||||
districts,
|
const districts = await this.marketIndexRepo.getMarketReport(
|
||||||
};
|
query.city,
|
||||||
|
query.period,
|
||||||
|
query.propertyType,
|
||||||
|
);
|
||||||
|
return { city: query.city, period: query.period, districts };
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'market_report',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetPriceTrendQuery } from './get-price-trend.query';
|
import { GetPriceTrendQuery } from './get-price-trend.query';
|
||||||
import {
|
import {
|
||||||
MARKET_INDEX_REPOSITORY,
|
MARKET_INDEX_REPOSITORY,
|
||||||
@@ -18,21 +19,31 @@ export interface PriceTrendDto {
|
|||||||
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
|
export class GetPriceTrendHandler implements IQueryHandler<GetPriceTrendQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
@Inject(MARKET_INDEX_REPOSITORY) private readonly marketIndexRepo: IMarketIndexRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
|
async execute(query: GetPriceTrendQuery): Promise<PriceTrendDto> {
|
||||||
const trend = await this.marketIndexRepo.getPriceTrend(
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.MARKET_TREND,
|
||||||
query.district,
|
query.district,
|
||||||
query.city,
|
query.city,
|
||||||
query.propertyType,
|
query.propertyType,
|
||||||
query.periods,
|
query.periods?.join(','),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return this.cache.getOrSet(
|
||||||
district: query.district,
|
cacheKey,
|
||||||
city: query.city,
|
async () => {
|
||||||
propertyType: query.propertyType,
|
const trend = await this.marketIndexRepo.getPriceTrend(
|
||||||
trend,
|
query.district,
|
||||||
};
|
query.city,
|
||||||
|
query.propertyType,
|
||||||
|
query.periods,
|
||||||
|
);
|
||||||
|
return { district: query.district, city: query.city, propertyType: query.propertyType, trend };
|
||||||
|
},
|
||||||
|
CacheTTL.MARKET_DATA,
|
||||||
|
'price_trend',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { Inject, NotFoundException } from '@nestjs/common';
|
import { Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { VerifyKycCommand } from './verify-kyc.command';
|
import { VerifyKycCommand } from './verify-kyc.command';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositor
|
|||||||
export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
|
export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: VerifyKycCommand): Promise<void> {
|
async execute(command: VerifyKycCommand): Promise<void> {
|
||||||
@@ -17,5 +19,7 @@ export class VerifyKycHandler implements ICommandHandler<VerifyKycCommand> {
|
|||||||
|
|
||||||
user.updateKycStatus(command.kycStatus, command.kycData);
|
user.updateKycStatus(command.kycStatus, command.kycData);
|
||||||
await this.userRepo.update(user);
|
await this.userRepo.update(user);
|
||||||
|
|
||||||
|
await this.cache.invalidate(CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject, NotFoundException } from '@nestjs/common';
|
import { Inject, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetProfileQuery } from './get-profile.query';
|
import { GetProfileQuery } from './get-profile.query';
|
||||||
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
|
|
||||||
@@ -19,24 +20,34 @@ export interface UserProfileDto {
|
|||||||
export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
|
export class GetProfileHandler implements IQueryHandler<GetProfileQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
|
async execute(query: GetProfileQuery): Promise<UserProfileDto> {
|
||||||
const user = await this.userRepo.findById(query.userId);
|
const cacheKey = CacheService.buildKey(CachePrefix.USER_PROFILE, query.userId);
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('Người dùng không tồn tại');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return this.cache.getOrSet(
|
||||||
id: user.id,
|
cacheKey,
|
||||||
email: user.email?.value ?? null,
|
async () => {
|
||||||
phone: user.phone.value,
|
const user = await this.userRepo.findById(query.userId);
|
||||||
fullName: user.fullName,
|
if (!user) {
|
||||||
avatarUrl: user.avatarUrl,
|
throw new NotFoundException('Người dùng không tồn tại');
|
||||||
role: user.role,
|
}
|
||||||
kycStatus: user.kycStatus,
|
|
||||||
isActive: user.isActive,
|
return {
|
||||||
createdAt: user.createdAt,
|
id: user.id,
|
||||||
};
|
email: user.email?.value ?? null,
|
||||||
|
phone: user.phone.value,
|
||||||
|
fullName: user.fullName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
kycStatus: user.kycStatus,
|
||||||
|
isActive: user.isActive,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
CacheTTL.USER_PROFILE,
|
||||||
|
'user_profile',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { Inject, BadRequestException } from '@nestjs/common';
|
import { Inject, BadRequestException } from '@nestjs/common';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { CreateListingCommand } from './create-listing.command';
|
import { CreateListingCommand } from './create-listing.command';
|
||||||
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
|
import { PROPERTY_REPOSITORY, type IPropertyRepository } from '../../../domain/repositories/property.repository';
|
||||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||||
@@ -22,6 +23,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
|
|||||||
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
|
||||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||||
private readonly eventBus: EventBus,
|
private readonly eventBus: EventBus,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
|
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
|
||||||
@@ -87,6 +89,8 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
|
|||||||
this.eventBus.publish(event);
|
this.eventBus.publish(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.cache.invalidateByPrefix(CachePrefix.SEARCH);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listingId,
|
listingId,
|
||||||
propertyId,
|
propertyId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { ModerateListingCommand } from './moderate-listing.command';
|
import { ModerateListingCommand } from './moderate-listing.command';
|
||||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||||
private readonly eventBus: EventBus,
|
private readonly eventBus: EventBus,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
|
async execute(command: ModerateListingCommand): Promise<{ status: string }> {
|
||||||
@@ -34,6 +36,11 @@ export class ModerateListingHandler implements ICommandHandler<ModerateListingCo
|
|||||||
this.eventBus.publish(event);
|
this.eventBus.publish(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||||
|
]);
|
||||||
|
|
||||||
return { status: listing.status };
|
return { status: listing.status };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { UpdateListingStatusCommand } from './update-listing-status.command';
|
import { UpdateListingStatusCommand } from './update-listing-status.command';
|
||||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||||
private readonly eventBus: EventBus,
|
private readonly eventBus: EventBus,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
|
async execute(command: UpdateListingStatusCommand): Promise<{ status: string }> {
|
||||||
@@ -32,6 +34,11 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
|
|||||||
this.eventBus.publish(event);
|
this.eventBus.publish(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.cache.invalidate(CacheService.buildKey(CachePrefix.LISTING, command.listingId)),
|
||||||
|
this.cache.invalidateByPrefix(CachePrefix.SEARCH),
|
||||||
|
]);
|
||||||
|
|
||||||
return { status: listing.status };
|
return { status: listing.status };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { GetListingQuery } from './get-listing.query';
|
import { GetListingQuery } from './get-listing.query';
|
||||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
@@ -59,13 +60,23 @@ export interface ListingDetailDto {
|
|||||||
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
|
export class GetListingHandler implements IQueryHandler<GetListingQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetListingQuery): Promise<ListingDetailDto> {
|
async execute(query: GetListingQuery): Promise<ListingDetailDto> {
|
||||||
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
|
const cacheKey = CacheService.buildKey(CachePrefix.LISTING, query.listingId);
|
||||||
if (!result) {
|
|
||||||
throw new NotFoundException('Listing', query.listingId);
|
return this.cache.getOrSet(
|
||||||
}
|
cacheKey,
|
||||||
return result as unknown as ListingDetailDto;
|
async () => {
|
||||||
|
const result = await this.listingRepo.findByIdWithProperty(query.listingId);
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundException('Listing', query.listingId);
|
||||||
|
}
|
||||||
|
return result as unknown as ListingDetailDto;
|
||||||
|
},
|
||||||
|
CacheTTL.LISTING_DETAIL,
|
||||||
|
'listing',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ import {
|
|||||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
|
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ── Cache Metrics ──
|
||||||
|
makeCounterProvider({
|
||||||
|
name: 'cache_hit_total',
|
||||||
|
help: 'Total number of cache hits',
|
||||||
|
labelNames: ['resource'],
|
||||||
|
}),
|
||||||
|
makeCounterProvider({
|
||||||
|
name: 'cache_miss_total',
|
||||||
|
help: 'Total number of cache misses',
|
||||||
|
labelNames: ['resource'],
|
||||||
|
}),
|
||||||
|
|
||||||
// ── Business Metrics ──
|
// ── Business Metrics ──
|
||||||
makeCounterProvider({
|
makeCounterProvider({
|
||||||
name: 'listings_created_total',
|
name: 'listings_created_total',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SearchPropertiesHandler } from '../queries/search-properties/search-properties.handler';
|
import { SearchPropertiesHandler } from '../queries/search-properties/search-properties.handler';
|
||||||
import { SearchPropertiesQuery } from '../queries/search-properties/search-properties.query';
|
import { SearchPropertiesQuery } from '../queries/search-properties/search-properties.query';
|
||||||
import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository';
|
import { type ISearchRepository, type SearchResult } from '../../domain/repositories/search.repository';
|
||||||
|
import { type CacheService } from '@modules/shared/infrastructure/cache.service';
|
||||||
|
|
||||||
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
|
function createMockSearchResult(overrides?: Partial<SearchResult>): SearchResult {
|
||||||
return {
|
return {
|
||||||
@@ -27,7 +28,8 @@ describe('SearchPropertiesHandler', () => {
|
|||||||
ensureCollection: vi.fn(),
|
ensureCollection: vi.fn(),
|
||||||
dropCollection: vi.fn(),
|
dropCollection: vi.fn(),
|
||||||
};
|
};
|
||||||
handler = new SearchPropertiesHandler(mockSearchRepo as any);
|
const mockCache = { getOrSet: vi.fn((_key: string, loader: () => Promise<unknown>) => loader()) } as unknown as CacheService;
|
||||||
|
handler = new SearchPropertiesHandler(mockSearchRepo as any, mockCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches with basic query', async () => {
|
it('searches with basic query', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '@modules/shared/infrastructure/cache.service';
|
||||||
import { SearchPropertiesQuery } from './search-properties.query';
|
import { SearchPropertiesQuery } from './search-properties.query';
|
||||||
import {
|
import {
|
||||||
SEARCH_REPOSITORY,
|
SEARCH_REPOSITORY,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQuery> {
|
export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQuery> {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
|
@Inject(SEARCH_REPOSITORY) private readonly searchRepo: ISearchRepository,
|
||||||
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: SearchPropertiesQuery): Promise<SearchResult> {
|
async execute(query: SearchPropertiesQuery): Promise<SearchResult> {
|
||||||
@@ -46,12 +48,36 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
|||||||
filters.push(`city:=${query.city}`);
|
filters.push(`city:=${query.city}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.searchRepo.search({
|
const searchParams = {
|
||||||
query: query.query,
|
query: query.query,
|
||||||
filterBy: filters.join(' && '),
|
filterBy: filters.join(' && '),
|
||||||
sortBy: query.sortBy,
|
sortBy: query.sortBy,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
perPage: query.perPage,
|
perPage: query.perPage,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const cacheKey = CacheService.buildKey(
|
||||||
|
CachePrefix.SEARCH,
|
||||||
|
query.query ?? '*',
|
||||||
|
query.propertyType,
|
||||||
|
query.transactionType,
|
||||||
|
query.district,
|
||||||
|
query.city,
|
||||||
|
query.page,
|
||||||
|
query.perPage,
|
||||||
|
query.priceMin,
|
||||||
|
query.priceMax,
|
||||||
|
query.areaMin,
|
||||||
|
query.areaMax,
|
||||||
|
query.bedrooms,
|
||||||
|
query.sortBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.cache.getOrSet(
|
||||||
|
cacheKey,
|
||||||
|
() => this.searchRepo.search(searchParams),
|
||||||
|
CacheTTL.SEARCH_RESULTS,
|
||||||
|
'search',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { CacheService, CachePrefix, CacheTTL } from '../cache.service';
|
||||||
|
|
||||||
|
describe('CacheService', () => {
|
||||||
|
let cacheService: CacheService;
|
||||||
|
let mockRedis: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
del: ReturnType<typeof vi.fn>;
|
||||||
|
getClient: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||||
|
let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
|
let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRedis = {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
getClient: vi.fn().mockReturnValue({
|
||||||
|
scan: vi.fn().mockResolvedValue(['0', []]),
|
||||||
|
del: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockLogger = { log: vi.fn(), warn: vi.fn() };
|
||||||
|
mockHitCounter = { inc: vi.fn() };
|
||||||
|
mockMissCounter = { inc: vi.fn() };
|
||||||
|
|
||||||
|
cacheService = new CacheService(
|
||||||
|
mockRedis as any,
|
||||||
|
mockLogger as any,
|
||||||
|
mockHitCounter as any,
|
||||||
|
mockMissCounter as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOrSet', () => {
|
||||||
|
it('should return cached value on cache hit', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(JSON.stringify({ id: '123', name: 'test' }));
|
||||||
|
const loader = vi.fn();
|
||||||
|
|
||||||
|
const result = await cacheService.getOrSet('cache:listing:123', loader, 300, 'listing');
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: '123', name: 'test' });
|
||||||
|
expect(loader).not.toHaveBeenCalled();
|
||||||
|
expect(mockHitCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||||
|
expect(mockMissCounter.inc).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call loader and cache result on cache miss', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
const data = { id: '456', name: 'loaded' };
|
||||||
|
const loader = vi.fn().mockResolvedValue(data);
|
||||||
|
|
||||||
|
const result = await cacheService.getOrSet('cache:listing:456', loader, 300, 'listing');
|
||||||
|
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
expect(loader).toHaveBeenCalledOnce();
|
||||||
|
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
|
||||||
|
expect(mockRedis.set).toHaveBeenCalledWith('cache:listing:456', JSON.stringify(data), 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call loader when cache read fails', async () => {
|
||||||
|
mockRedis.get.mockRejectedValue(new Error('connection lost'));
|
||||||
|
const data = { id: '789' };
|
||||||
|
const loader = vi.fn().mockResolvedValue(data);
|
||||||
|
|
||||||
|
const result = await cacheService.getOrSet('key', loader, 60, 'search');
|
||||||
|
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalled();
|
||||||
|
expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'search' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return loaded data even when cache write fails', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
mockRedis.set.mockRejectedValue(new Error('write error'));
|
||||||
|
const data = { id: '999' };
|
||||||
|
const loader = vi.fn().mockResolvedValue(data);
|
||||||
|
|
||||||
|
const result = await cacheService.getOrSet('key', loader, 60, 'search');
|
||||||
|
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate loader errors', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
const loader = vi.fn().mockRejectedValue(new Error('not found'));
|
||||||
|
|
||||||
|
await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidate', () => {
|
||||||
|
it('should delete the cache key', async () => {
|
||||||
|
await cacheService.invalidate('cache:listing:123');
|
||||||
|
expect(mockRedis.del).toHaveBeenCalledWith('cache:listing:123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle delete errors gracefully', async () => {
|
||||||
|
mockRedis.del.mockRejectedValue(new Error('fail'));
|
||||||
|
await cacheService.invalidate('cache:listing:123');
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidateByPrefix', () => {
|
||||||
|
it('should scan and delete matching keys', async () => {
|
||||||
|
const mockClient = {
|
||||||
|
scan: vi.fn()
|
||||||
|
.mockResolvedValueOnce(['10', ['cache:search:a', 'cache:search:b']])
|
||||||
|
.mockResolvedValueOnce(['0', ['cache:search:c']]),
|
||||||
|
del: vi.fn(),
|
||||||
|
};
|
||||||
|
mockRedis.getClient.mockReturnValue(mockClient);
|
||||||
|
|
||||||
|
await cacheService.invalidateByPrefix('cache:search');
|
||||||
|
|
||||||
|
expect(mockClient.scan).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockClient.del).toHaveBeenCalledWith('cache:search:a', 'cache:search:b');
|
||||||
|
expect(mockClient.del).toHaveBeenCalledWith('cache:search:c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildKey', () => {
|
||||||
|
it('should build a deterministic cache key', () => {
|
||||||
|
const key = CacheService.buildKey(CachePrefix.LISTING, 'abc123');
|
||||||
|
expect(key).toBe('cache:listing:abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple parts', () => {
|
||||||
|
const key = CacheService.buildKey(CachePrefix.MARKET_REPORT, 'Hà Nội', '2026-Q1', 'APARTMENT');
|
||||||
|
expect(key).toBe('cache:market:report:hà_nội:2026-q1:apartment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip undefined parts', () => {
|
||||||
|
const key = CacheService.buildKey(CachePrefix.SEARCH, 'query', undefined, 'SALE');
|
||||||
|
expect(key).toBe('cache:search:query:sale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric parts', () => {
|
||||||
|
const key = CacheService.buildKey(CachePrefix.SEARCH, 'test', 1, 20);
|
||||||
|
expect(key).toBe('cache:search:test:1:20');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CacheTTL', () => {
|
||||||
|
it('should have correct TTL values', () => {
|
||||||
|
expect(CacheTTL.LISTING_DETAIL).toBe(300);
|
||||||
|
expect(CacheTTL.SEARCH_RESULTS).toBe(60);
|
||||||
|
expect(CacheTTL.MARKET_DATA).toBe(1800);
|
||||||
|
expect(CacheTTL.USER_PROFILE).toBe(600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/api/src/modules/shared/infrastructure/cache.service.ts
Normal file
108
apps/api/src/modules/shared/infrastructure/cache.service.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable, Inject, type OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||||
|
import { Counter } from 'prom-client';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
import { LoggerService } from './logger.service';
|
||||||
|
|
||||||
|
export const CACHE_HIT_TOTAL = 'cache_hit_total';
|
||||||
|
export const CACHE_MISS_TOTAL = 'cache_miss_total';
|
||||||
|
|
||||||
|
export enum CacheTTL {
|
||||||
|
/** Listing detail — moderate TTL, invalidated on mutation */
|
||||||
|
LISTING_DETAIL = 300, // 5 min
|
||||||
|
/** Search results — short TTL due to high variability */
|
||||||
|
SEARCH_RESULTS = 60, // 1 min
|
||||||
|
/** Market analytics — long TTL, data changes infrequently */
|
||||||
|
MARKET_DATA = 1800, // 30 min
|
||||||
|
/** User profile — moderate TTL, invalidated on mutation */
|
||||||
|
USER_PROFILE = 600, // 10 min
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CachePrefix {
|
||||||
|
LISTING = 'cache:listing',
|
||||||
|
SEARCH = 'cache:search',
|
||||||
|
MARKET_REPORT = 'cache:market:report',
|
||||||
|
MARKET_TREND = 'cache:market:trend',
|
||||||
|
MARKET_HEATMAP = 'cache:market:heatmap',
|
||||||
|
MARKET_DISTRICT = 'cache:market:district',
|
||||||
|
USER_PROFILE = 'cache:user:profile',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CacheService implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
@InjectMetric(CACHE_HIT_TOTAL) private readonly cacheHitCounter: Counter,
|
||||||
|
@InjectMetric(CACHE_MISS_TOTAL) private readonly cacheMissCounter: Counter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.logger.log('CacheService initialized', 'CacheService');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-aside: get from cache, or execute loader and store result.
|
||||||
|
*/
|
||||||
|
async getOrSet<T>(
|
||||||
|
key: string,
|
||||||
|
loader: () => Promise<T>,
|
||||||
|
ttlSeconds: number,
|
||||||
|
resource: string,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const cached = await this.redis.get(key);
|
||||||
|
if (cached !== null) {
|
||||||
|
this.cacheHitCounter.inc({ resource });
|
||||||
|
return JSON.parse(cached) as T;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cache read error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cacheMissCounter.inc({ resource });
|
||||||
|
const result = await loader();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.redis.set(key, JSON.stringify(result), ttlSeconds);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cache write error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate a single cache key. */
|
||||||
|
async invalidate(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.redis.del(key);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cache invalidate error for ${key}: ${(err as Error).message}`, 'CacheService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate all keys matching a prefix using SCAN (non-blocking). */
|
||||||
|
async invalidateByPrefix(prefix: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const client = this.redis.getClient();
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', `${prefix}:*`, 'COUNT', 100);
|
||||||
|
cursor = nextCursor;
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await client.del(...keys);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Cache prefix invalidate error for ${prefix}: ${(err as Error).message}`, 'CacheService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a deterministic cache key from prefix + parts. */
|
||||||
|
static buildKey(prefix: CachePrefix, ...parts: (string | number | undefined)[]): string {
|
||||||
|
const sanitized = parts
|
||||||
|
.filter((p) => p !== undefined)
|
||||||
|
.map((p) => String(p).toLowerCase().replace(/\s+/g, '_'));
|
||||||
|
return `${prefix}:${sanitized.join(':')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { PrismaService } from './prisma.service';
|
export { PrismaService } from './prisma.service';
|
||||||
export { RedisService } from './redis.service';
|
export { RedisService } from './redis.service';
|
||||||
|
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||||
export { LoggerService } from './logger.service';
|
export { LoggerService } from './logger.service';
|
||||||
export { EventBusService } from './event-bus.service';
|
export { EventBusService } from './event-bus.service';
|
||||||
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
export { GlobalExceptionFilter } from './filters/global-exception.filter';
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Global, type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
|
import { Global, type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common';
|
||||||
import { APP_FILTER } from '@nestjs/core';
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { EventBusService } from './infrastructure/event-bus.service';
|
import { EventBusService } from './infrastructure/event-bus.service';
|
||||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||||
import { LoggerService } from './infrastructure/logger.service';
|
import { LoggerService } from './infrastructure/logger.service';
|
||||||
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
import { CorrelationIdMiddleware } from './infrastructure/middleware/correlation-id.middleware';
|
||||||
|
import { CsrfMiddleware } from './infrastructure/middleware/csrf.middleware';
|
||||||
import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware';
|
import { RequestLoggingMiddleware } from './infrastructure/middleware/request-logging.middleware';
|
||||||
import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
|
import { SanitizeInputMiddleware } from './infrastructure/middleware/sanitize-input.middleware';
|
||||||
import { PrismaService } from './infrastructure/prisma.service';
|
import { PrismaService } from './infrastructure/prisma.service';
|
||||||
import { RedisService } from './infrastructure/redis.service';
|
import { RedisService } from './infrastructure/redis.service';
|
||||||
|
import { CacheService } from './infrastructure/cache.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -16,6 +18,7 @@ import { RedisService } from './infrastructure/redis.service';
|
|||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
RedisService,
|
RedisService,
|
||||||
|
CacheService,
|
||||||
LoggerService,
|
LoggerService,
|
||||||
EventBusService,
|
EventBusService,
|
||||||
{
|
{
|
||||||
@@ -23,12 +26,17 @@ import { RedisService } from './infrastructure/redis.service';
|
|||||||
useClass: GlobalExceptionFilter,
|
useClass: GlobalExceptionFilter,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [PrismaService, RedisService, LoggerService, EventBusService],
|
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService],
|
||||||
})
|
})
|
||||||
export class SharedModule implements NestModule {
|
export class SharedModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer
|
consumer
|
||||||
.apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware)
|
.apply(CorrelationIdMiddleware, SanitizeInputMiddleware, RequestLoggingMiddleware)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
|
|
||||||
|
consumer
|
||||||
|
.apply(CsrfMiddleware)
|
||||||
|
.exclude({ path: 'payments/callback/(.*)', method: RequestMethod.POST })
|
||||||
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user