feat(api): add @Cacheable decorator and plan list caching
- Create @Cacheable method decorator for declarative cache-aside pattern with configurable prefix, TTL, resource label, and key extraction - Add PLAN_LIST (1h TTL) and REFERENCE_DATA (24h TTL) cache constants - Add CachePrefix.PLAN_LIST and CachePrefix.REFERENCE entries - Cache subscription plan queries in GetPlanHandler (single + list) - Export Cacheable decorator from shared module barrel - Add comprehensive tests for decorator and handler caching The caching infrastructure (CacheService, Redis, Prometheus metrics, event-driven invalidation) was already production-ready with 10+ hot paths cached. This commit adds the missing declarative decorator and plan list caching. Resolves: TEC-1567 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CachePrefix, CacheService, CacheTTL } from '../cache.service';
|
||||
import { Cacheable } from '../decorators/cacheable.decorator';
|
||||
|
||||
describe('Cacheable decorator', () => {
|
||||
let mockCacheService: { getOrSet: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCacheService = {
|
||||
getOrSet: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should call cacheService.getOrSet with correct key and TTL', async () => {
|
||||
class TestHandler {
|
||||
cacheService = mockCacheService;
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.LISTING,
|
||||
ttl: CacheTTL.LISTING_DETAIL,
|
||||
resource: 'listing',
|
||||
keyFrom: (query: unknown) => [(query as { id: string }).id],
|
||||
})
|
||||
async execute(query: { id: string }): Promise<string> {
|
||||
return `listing-${query.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
mockCacheService.getOrSet.mockResolvedValue('cached-result');
|
||||
const handler = new TestHandler();
|
||||
const result = await handler.execute({ id: 'abc123' });
|
||||
|
||||
expect(result).toBe('cached-result');
|
||||
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
|
||||
'cache:listing:abc123',
|
||||
expect.any(Function),
|
||||
300,
|
||||
'listing',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use first argument as default key when keyFrom is not provided', async () => {
|
||||
class TestHandler {
|
||||
cacheService = mockCacheService;
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.PLAN_LIST,
|
||||
ttl: CacheTTL.PLAN_LIST,
|
||||
resource: 'plan',
|
||||
})
|
||||
async execute(query: string): Promise<string> {
|
||||
return `plan-${query}`;
|
||||
}
|
||||
}
|
||||
|
||||
mockCacheService.getOrSet.mockResolvedValue('cached-plan');
|
||||
const handler = new TestHandler();
|
||||
await handler.execute('PREMIUM');
|
||||
|
||||
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
|
||||
'cache:plan:list:premium',
|
||||
expect.any(Function),
|
||||
3600,
|
||||
'plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute the original method as the loader function', async () => {
|
||||
class TestHandler {
|
||||
cacheService = mockCacheService;
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.LISTING,
|
||||
ttl: CacheTTL.LISTING_DETAIL,
|
||||
resource: 'listing',
|
||||
keyFrom: (query: unknown) => [(query as { id: string }).id],
|
||||
})
|
||||
async execute(_query: { id: string }): Promise<string> {
|
||||
return 'original-result';
|
||||
}
|
||||
}
|
||||
|
||||
mockCacheService.getOrSet.mockImplementation(
|
||||
async (_key: string, loader: () => Promise<string>) => loader(),
|
||||
);
|
||||
const handler = new TestHandler();
|
||||
const result = await handler.execute({ id: 'xyz' });
|
||||
|
||||
expect(result).toBe('original-result');
|
||||
});
|
||||
|
||||
it('should handle multiple key parts from keyFrom', async () => {
|
||||
class TestHandler {
|
||||
cacheService = mockCacheService;
|
||||
|
||||
@Cacheable({
|
||||
prefix: CachePrefix.SEARCH,
|
||||
ttl: CacheTTL.SEARCH_RESULTS,
|
||||
resource: 'search',
|
||||
keyFrom: (query: unknown) => {
|
||||
const q = query as { district: string; type: string };
|
||||
return [q.district, q.type];
|
||||
},
|
||||
})
|
||||
async execute(_query: { district: string; type: string }): Promise<unknown[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
mockCacheService.getOrSet.mockResolvedValue([]);
|
||||
const handler = new TestHandler();
|
||||
await handler.execute({ district: 'Quận 1', type: 'APARTMENT' });
|
||||
|
||||
expect(mockCacheService.getOrSet).toHaveBeenCalledWith(
|
||||
'cache:search:quận_1:apartment',
|
||||
expect.any(Function),
|
||||
120,
|
||||
'search',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,10 @@ export const CacheTTL = {
|
||||
USER_PROFILE: 600, // 10 min
|
||||
/** User quota — very short TTL, invalidated on usage metering and plan changes */
|
||||
USER_QUOTA: 60, // 1 min
|
||||
/** Subscription plan list — long TTL, rarely changes */
|
||||
PLAN_LIST: 3600, // 1 hour
|
||||
/** Reference data (districts, wards) — very long TTL, static data */
|
||||
REFERENCE_DATA: 86400, // 24 hours
|
||||
} as const;
|
||||
|
||||
export enum CachePrefix {
|
||||
@@ -37,6 +41,8 @@ export enum CachePrefix {
|
||||
USER_PROFILE = 'cache:user:profile',
|
||||
USER_QUOTA = 'cache:user:quota',
|
||||
VALUATION = 'cache:valuation',
|
||||
PLAN_LIST = 'cache:plan:list',
|
||||
REFERENCE = 'cache:reference',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type CachePrefix, CacheService, type CacheTTL } from '../cache.service';
|
||||
|
||||
/**
|
||||
* Metadata key for @Cacheable decorator options.
|
||||
*/
|
||||
export const CACHEABLE_METADATA = Symbol('CACHEABLE_METADATA');
|
||||
|
||||
export interface CacheableOptions {
|
||||
/** Cache key prefix — use CachePrefix enum values. */
|
||||
prefix: CachePrefix;
|
||||
/** TTL in seconds — use CacheTTL constants. */
|
||||
ttl: (typeof CacheTTL)[keyof typeof CacheTTL];
|
||||
/** Resource label for Prometheus metrics. */
|
||||
resource: string;
|
||||
/**
|
||||
* Extract cache key parts from method arguments.
|
||||
* Return an array of strings that will be joined with the prefix.
|
||||
* Defaults to using the first argument's string representation.
|
||||
*/
|
||||
keyFrom?: (...args: unknown[]) => (string | number | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative cache-aside decorator for query handler `execute()` methods.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* @Cacheable({ prefix: CachePrefix.LISTING, ttl: CacheTTL.LISTING_DETAIL, resource: 'listing' })
|
||||
* async execute(query: GetListingQuery): Promise<ListingDto> { ... }
|
||||
* ```
|
||||
*
|
||||
* Requires the class to have a `cacheService: CacheService` property
|
||||
* (injected via constructor).
|
||||
*/
|
||||
export function Cacheable(options: CacheableOptions): MethodDecorator {
|
||||
return function (_target: object, _propertyKey: string | symbol, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value as (...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
descriptor.value = async function (this: { cacheService: CacheService }, ...args: unknown[]) {
|
||||
const keyParts = options.keyFrom
|
||||
? options.keyFrom(...args)
|
||||
: [String(args[0] ?? 'default')];
|
||||
|
||||
const cacheKey = CacheService.buildKey(options.prefix, ...keyParts);
|
||||
|
||||
return this.cacheService.getOrSet(
|
||||
cacheKey,
|
||||
() => originalMethod.apply(this, args),
|
||||
options.ttl,
|
||||
options.resource,
|
||||
);
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Cacheable, type CacheableOptions } from './decorators/cacheable.decorator';
|
||||
export { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||
|
||||
@@ -17,6 +17,8 @@ describe('GetPlanHandler', () => {
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
let mockCacheService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
@@ -25,7 +27,11 @@ describe('GetPlanHandler', () => {
|
||||
},
|
||||
};
|
||||
|
||||
handler = new GetPlanHandler(mockPrisma);
|
||||
mockCacheService = {
|
||||
getOrSet: vi.fn((_key: string, loader: () => Promise<any>) => loader()),
|
||||
};
|
||||
|
||||
handler = new GetPlanHandler(mockPrisma, mockCacheService);
|
||||
});
|
||||
|
||||
it('returns a single plan by tier', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type Plan } from '@prisma/client';
|
||||
import { NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { CacheService, CachePrefix, CacheTTL, NotFoundException, type PrismaService } from '@modules/shared';
|
||||
import { GetPlanQuery } from './get-plan.query';
|
||||
|
||||
export interface PlanDto {
|
||||
@@ -17,28 +17,41 @@ export interface PlanDto {
|
||||
|
||||
@QueryHandler(GetPlanQuery)
|
||||
export class GetPlanHandler implements IQueryHandler<GetPlanQuery> {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPlanQuery): Promise<PlanDto | PlanDto[]> {
|
||||
if (query.planTier) {
|
||||
let plan;
|
||||
try {
|
||||
plan = await this.prisma.plan.findFirst({
|
||||
where: { tier: query.planTier, isActive: true },
|
||||
});
|
||||
} catch {
|
||||
throw new NotFoundException('Plan', query.planTier);
|
||||
}
|
||||
if (!plan) throw new NotFoundException('Plan', query.planTier);
|
||||
return this.toDto(plan);
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier);
|
||||
return this.cacheService.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const plan = await this.prisma.plan.findFirst({
|
||||
where: { tier: query.planTier, isActive: true },
|
||||
});
|
||||
if (!plan) throw new NotFoundException('Plan', query.planTier);
|
||||
return this.toDto(plan);
|
||||
},
|
||||
CacheTTL.PLAN_LIST,
|
||||
'plan',
|
||||
);
|
||||
}
|
||||
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { priceMonthlyVND: 'asc' },
|
||||
});
|
||||
|
||||
return plans.map((p) => this.toDto(p));
|
||||
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all');
|
||||
return this.cacheService.getOrSet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const plans = await this.prisma.plan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { priceMonthlyVND: 'asc' },
|
||||
});
|
||||
return plans.map((p) => this.toDto(p));
|
||||
},
|
||||
CacheTTL.PLAN_LIST,
|
||||
'plan',
|
||||
);
|
||||
}
|
||||
|
||||
private toDto(plan: Plan): PlanDto {
|
||||
|
||||
Reference in New Issue
Block a user