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_PROFILE: 600, // 10 min
|
||||||
/** User quota — very short TTL, invalidated on usage metering and plan changes */
|
/** User quota — very short TTL, invalidated on usage metering and plan changes */
|
||||||
USER_QUOTA: 60, // 1 min
|
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;
|
} as const;
|
||||||
|
|
||||||
export enum CachePrefix {
|
export enum CachePrefix {
|
||||||
@@ -37,6 +41,8 @@ export enum CachePrefix {
|
|||||||
USER_PROFILE = 'cache:user:profile',
|
USER_PROFILE = 'cache:user:profile',
|
||||||
USER_QUOTA = 'cache:user:quota',
|
USER_QUOTA = 'cache:user:quota',
|
||||||
VALUATION = 'cache:valuation',
|
VALUATION = 'cache:valuation',
|
||||||
|
PLAN_LIST = 'cache:plan:list',
|
||||||
|
REFERENCE = 'cache:reference',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@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 { PrismaService } from './prisma.service';
|
||||||
export { RedisService } from './redis.service';
|
export { RedisService } from './redis.service';
|
||||||
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
export { CacheService, CachePrefix, CacheTTL } from './cache.service';
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('GetPlanHandler', () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mockCacheService: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
plan: {
|
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 () => {
|
it('returns a single plan by tier', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { type Plan } from '@prisma/client';
|
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';
|
import { GetPlanQuery } from './get-plan.query';
|
||||||
|
|
||||||
export interface PlanDto {
|
export interface PlanDto {
|
||||||
@@ -17,28 +17,41 @@ export interface PlanDto {
|
|||||||
|
|
||||||
@QueryHandler(GetPlanQuery)
|
@QueryHandler(GetPlanQuery)
|
||||||
export class GetPlanHandler implements IQueryHandler<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[]> {
|
async execute(query: GetPlanQuery): Promise<PlanDto | PlanDto[]> {
|
||||||
if (query.planTier) {
|
if (query.planTier) {
|
||||||
let plan;
|
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, query.planTier);
|
||||||
try {
|
return this.cacheService.getOrSet(
|
||||||
plan = await this.prisma.plan.findFirst({
|
cacheKey,
|
||||||
where: { tier: query.planTier, isActive: true },
|
async () => {
|
||||||
});
|
const plan = await this.prisma.plan.findFirst({
|
||||||
} catch {
|
where: { tier: query.planTier, isActive: true },
|
||||||
throw new NotFoundException('Plan', query.planTier);
|
});
|
||||||
}
|
if (!plan) throw new NotFoundException('Plan', query.planTier);
|
||||||
if (!plan) throw new NotFoundException('Plan', query.planTier);
|
return this.toDto(plan);
|
||||||
return this.toDto(plan);
|
},
|
||||||
|
CacheTTL.PLAN_LIST,
|
||||||
|
'plan',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const plans = await this.prisma.plan.findMany({
|
const cacheKey = CacheService.buildKey(CachePrefix.PLAN_LIST, 'all');
|
||||||
where: { isActive: true },
|
return this.cacheService.getOrSet(
|
||||||
orderBy: { priceMonthlyVND: 'asc' },
|
cacheKey,
|
||||||
});
|
async () => {
|
||||||
|
const plans = await this.prisma.plan.findMany({
|
||||||
return plans.map((p) => this.toDto(p));
|
where: { isActive: true },
|
||||||
|
orderBy: { priceMonthlyVND: 'asc' },
|
||||||
|
});
|
||||||
|
return plans.map((p) => this.toDto(p));
|
||||||
|
},
|
||||||
|
CacheTTL.PLAN_LIST,
|
||||||
|
'plan',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDto(plan: Plan): PlanDto {
|
private toDto(plan: Plan): PlanDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user