codex/backend-architecture-refactor #48
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal file
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal file
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
28
apps/backend/src/modules/cache/cache.service.ts
vendored
28
apps/backend/src/modules/cache/cache.service.ts
vendored
@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
|||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
type CacheEntry<T> = {
|
||||||
|
data: T;
|
||||||
|
cachedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -18,6 +23,16 @@ export class CacheService {
|
|||||||
await this.cacheManager.set(key, value, ttl);
|
await this.cacheManager.set(key, value, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'data' in value &&
|
||||||
|
'cachedAt' in value &&
|
||||||
|
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private buildKey(...parts: string[]): string {
|
private buildKey(...parts: string[]): string {
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
@ -31,14 +46,19 @@ export class CacheService {
|
|||||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||||||
|
|
||||||
const cached = await this.get<T>(key);
|
const cached = await this.get<CacheEntry<T> | T>(key);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return { data: cached, fromCache: true, cachedAt: null };
|
if (this.isCacheEntry<T>(cached)) {
|
||||||
|
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: cached as T, fromCache: true, cachedAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await fetchFn();
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user