diff --git a/apps/backend/src/modules/cache/cache.service.spec.ts b/apps/backend/src/modules/cache/cache.service.spec.ts new file mode 100644 index 0000000..0c04fcd --- /dev/null +++ b/apps/backend/src/modules/cache/cache.service.spec.ts @@ -0,0 +1,65 @@ +import { ConfigService } from '@nestjs/config'; +import { CacheService } from './cache.service'; + +describe('CacheService', () => { + const configService = { + get: vi.fn((_key: string, fallback?: unknown) => fallback), + } as unknown as ConfigService; + + const createCache = () => ({ + get: vi.fn(), + set: vi.fn(), + }); + + it('stores data with cachedAt metadata on cache miss', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z')); + const cache = createCache(); + cache.get.mockResolvedValue(undefined); + const service = new CacheService(cache as never, configService); + + try { + const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey'); + + expect(result).toEqual({ + data: { value: 1 }, + fromCache: false, + cachedAt: '2026-06-25T10:00:00.000Z', + }); + expect(cache.set).toHaveBeenCalledWith( + 'prefix:a', + { data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' }, + 900, + ); + } finally { + vi.useRealTimers(); + } + }); + + it('returns cachedAt metadata on cache hit', async () => { + const cache = createCache(); + cache.get.mockResolvedValue({ + data: { value: 1 }, + cachedAt: '2026-06-25T10:00:00.000Z', + }); + const service = new CacheService(cache as never, configService); + + const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey'); + + expect(result).toEqual({ + data: { value: 1 }, + fromCache: true, + cachedAt: '2026-06-25T10:00:00.000Z', + }); + }); + + it('supports legacy raw cache values during rollout', async () => { + const cache = createCache(); + cache.get.mockResolvedValue({ value: 1 }); + const service = new CacheService(cache as never, configService); + + const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey'); + + expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null }); + }); +}); diff --git a/apps/backend/src/modules/cache/cache.service.ts b/apps/backend/src/modules/cache/cache.service.ts index 11efcd2..1919d4a 100644 --- a/apps/backend/src/modules/cache/cache.service.ts +++ b/apps/backend/src/modules/cache/cache.service.ts @@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { ConfigService } from '@nestjs/config'; +type CacheEntry = { + data: T; + cachedAt: string; +}; + @Injectable() export class CacheService { constructor( @@ -18,6 +23,16 @@ export class CacheService { await this.cacheManager.set(key, value, ttl); } + private isCacheEntry(value: unknown): value is CacheEntry { + return ( + typeof value === 'object' && + value !== null && + 'data' in value && + 'cachedAt' in value && + typeof (value as { cachedAt?: unknown }).cachedAt === 'string' + ); + } + private buildKey(...parts: string[]): string { return parts.join(':'); } @@ -31,14 +46,19 @@ export class CacheService { const key = this.buildKey(keyPrefix, ...keyParts); const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); - const cached = await this.get(key); + const cached = await this.get | T>(key); if (cached !== undefined) { - return { data: cached, fromCache: true, cachedAt: null }; + if (this.isCacheEntry(cached)) { + return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt }; + } + + return { data: cached as T, fromCache: true, cachedAt: null }; } const data = await fetchFn(); - await this.set(key, data, ttl); + const cachedAt = new Date().toISOString(); + await this.set(key, { data, cachedAt }, ttl); - return { data, fromCache: false, cachedAt: new Date().toISOString() }; + return { data, fromCache: false, cachedAt }; } }