codex/backend-architecture-refactor #48

Merged
ksv741 merged 9 commits from codex/backend-architecture-refactor into main 2026-06-25 22:14:39 +03:00
2 changed files with 89 additions and 4 deletions
Showing only changes of commit b87ed761ed - Show all commits

View 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 });
});
});

View File

@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
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<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 {
return parts.join(':');
}
@ -31,14 +46,19 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts);
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) {
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();
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 };
}
}