Files
telegram-bot-workers/tests/kv-cache.test.ts
kappa fbe696b88c refactor: complete P0-P1 improvements
Constants migration:
- server-agent.ts: SERVER_CONSULTATION_STATUS, LANGUAGE_CODE
- troubleshoot-agent.ts: TROUBLESHOOT_STATUS
- notification.ts: NOTIFICATION_TYPE

API improvements:
- search-tool.ts: Zod schema validation for Brave/Context7 APIs
- api-helper.ts: Centralized API call utility with retry/timeout

Testing:
- kv-cache.test.ts: 38 test cases for cache abstraction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:02:27 +09:00

499 lines
15 KiB
TypeScript

/**
* Comprehensive Unit Tests for kv-cache.ts
*
* 테스트 범위:
* 1. KVCache 기본 CRUD 작업 (get, set, delete)
* 2. TTL 기능 (expirationTtl)
* 3. getOrSet 패턴 (cache-aside pattern)
* 4. exists 체크
* 5. Rate limiting 로직
* 6. 에러 핸들링
* 7. Prefix 분리 (rate vs session)
*
* NOTE: 이 테스트는 DB가 필요 없으므로 setup.ts를 import하지 않습니다.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
KVCache,
createRateLimitCache,
createSessionCache,
checkRateLimitWithCache,
} from '../src/services/kv-cache';
/**
* Mock KVNamespace implementation
* - In-memory Map으로 실제 KV 동작 시뮬레이션
* - TTL 자동 만료 로직 포함
*/
const createMockKV = () => {
const store = new Map<string, { value: string; expiration?: number }>();
return {
get: vi.fn(async (key: string, type?: string) => {
const entry = store.get(key);
if (!entry) return null;
// TTL 만료 확인
if (entry.expiration && entry.expiration < Date.now() / 1000) {
store.delete(key);
return null;
}
// type이 'json'이면 파싱해서 반환
if (type === 'json') {
try {
return JSON.parse(entry.value);
} catch {
return null;
}
}
return entry.value;
}),
put: vi.fn(async (key: string, value: string, options?: { expirationTtl?: number }) => {
const expiration = options?.expirationTtl
? Math.floor(Date.now() / 1000) + options.expirationTtl
: undefined;
store.set(key, { value, expiration });
}),
delete: vi.fn(async (key: string) => {
store.delete(key);
}),
_store: store, // 테스트용 직접 접근
};
};
describe('KVCache - Basic Operations', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = new KVCache(mockKV as unknown as KVNamespace, 'test');
});
describe('get/set', () => {
it('should store and retrieve simple string values', async () => {
await cache.set('key1', 'value1');
const result = await cache.get<string>('key1');
expect(result).toBe('value1');
});
it('should store and retrieve object values', async () => {
const data = { name: 'John', age: 30 };
await cache.set('user', data);
const result = await cache.get<typeof data>('user');
expect(result).toEqual(data);
});
it('should return null for missing keys', async () => {
const result = await cache.get('nonexistent');
expect(result).toBeNull();
});
it('should use prefix in key names', async () => {
await cache.set('key1', 'value1');
expect(mockKV.put).toHaveBeenCalledWith(
'test:key1',
JSON.stringify('value1'),
undefined
);
});
it('should handle empty prefix', async () => {
const noPrefixCache = new KVCache(mockKV as unknown as KVNamespace, '');
await noPrefixCache.set('key1', 'value1');
expect(mockKV.put).toHaveBeenCalledWith('key1', JSON.stringify('value1'), undefined);
});
});
describe('TTL expiration', () => {
it('should store value with TTL', async () => {
await cache.set('key1', 'value1', 60);
expect(mockKV.put).toHaveBeenCalledWith(
'test:key1',
JSON.stringify('value1'),
{ expirationTtl: 60 }
);
});
it('should respect TTL and expire values', async () => {
// TTL 1초로 설정
await cache.set('key1', 'value1', 1);
// 1.1초 대기 (TTL 만료)
await new Promise(resolve => setTimeout(resolve, 1100));
const result = await cache.get('key1');
expect(result).toBeNull();
});
it('should not expire values without TTL', async () => {
await cache.set('key1', 'value1'); // TTL 없음
const result = await cache.get<string>('key1');
expect(result).toBe('value1');
});
});
describe('delete', () => {
it('should delete existing keys', async () => {
await cache.set('key1', 'value1');
const success = await cache.delete('key1');
expect(success).toBe(true);
expect(mockKV.delete).toHaveBeenCalledWith('test:key1');
const result = await cache.get('key1');
expect(result).toBeNull();
});
it('should return true even for non-existent keys', async () => {
const success = await cache.delete('nonexistent');
expect(success).toBe(true);
});
});
describe('exists', () => {
it('should return true for existing keys', async () => {
await cache.set('key1', 'value1');
const exists = await cache.exists('key1');
expect(exists).toBe(true);
});
it('should return false for missing keys', async () => {
const exists = await cache.exists('nonexistent');
expect(exists).toBe(false);
});
it('should return false for expired keys', async () => {
await cache.set('key1', 'value1', 1); // TTL 1초
await new Promise(resolve => setTimeout(resolve, 1100)); // 1.1초 대기
const exists = await cache.exists('key1');
expect(exists).toBe(false);
});
});
});
describe('KVCache - getOrSet Pattern', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = new KVCache(mockKV as unknown as KVNamespace, 'test');
});
it('should return cached value if exists', async () => {
const factory = vi.fn(async () => 'new-value');
// 먼저 캐시에 저장
await cache.set('key1', 'cached-value');
// getOrSet 호출
const result = await cache.getOrSet('key1', factory);
expect(result).toBe('cached-value');
expect(factory).not.toHaveBeenCalled(); // factory는 호출 안됨
});
it('should call factory and cache result if not exists', async () => {
const factory = vi.fn(async () => 'new-value');
const result = await cache.getOrSet('key1', factory);
expect(result).toBe('new-value');
expect(factory).toHaveBeenCalledTimes(1);
// 캐시에 저장되었는지 확인
const cached = await cache.get<string>('key1');
expect(cached).toBe('new-value');
});
it('should respect TTL in getOrSet', async () => {
const factory = vi.fn(async () => 'new-value');
await cache.getOrSet('key1', factory, 60);
expect(mockKV.put).toHaveBeenCalledWith(
'test:key1',
JSON.stringify('new-value'),
{ expirationTtl: 60 }
);
});
it('should handle factory errors gracefully', async () => {
const factory = vi.fn(async () => {
throw new Error('Factory failed');
});
await expect(cache.getOrSet('key1', factory)).rejects.toThrow('Factory failed');
// 캐시에 저장되지 않았는지 확인
const cached = await cache.get('key1');
expect(cached).toBeNull();
});
});
describe('KVCache - Error Handling', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = new KVCache(mockKV as unknown as KVNamespace, 'test');
});
it('should return null on get() error', async () => {
mockKV.get.mockRejectedValueOnce(new Error('KV get failed'));
const result = await cache.get('key1');
expect(result).toBeNull();
});
it('should return false on set() error', async () => {
mockKV.put.mockRejectedValueOnce(new Error('KV put failed'));
const success = await cache.set('key1', 'value1');
expect(success).toBe(false);
});
it('should return false on delete() error', async () => {
mockKV.delete.mockRejectedValueOnce(new Error('KV delete failed'));
const success = await cache.delete('key1');
expect(success).toBe(false);
});
it('should handle JSON parse errors', async () => {
// 잘못된 JSON을 직접 저장
mockKV._store.set('test:key1', { value: 'invalid-json{', expiration: undefined });
const result = await cache.get('key1');
expect(result).toBeNull();
});
});
describe('Rate Limiting', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = createRateLimitCache(mockKV as unknown as KVNamespace);
});
it('should use "rate" prefix', async () => {
await cache.set('user1', 'test');
expect(mockKV.put).toHaveBeenCalledWith(
'rate:user1',
expect.any(String),
undefined
);
});
describe('checkRateLimitWithCache', () => {
it('should allow first request', async () => {
const allowed = await checkRateLimitWithCache(cache, 'user1', 30, 60);
expect(allowed).toBe(true);
// count=1로 저장되었는지 확인
const data = await cache.get<{ count: number; windowStart: number }>('user1');
expect(data?.count).toBe(1);
});
it('should allow requests within limit', async () => {
// 30번 요청 허용
for (let i = 0; i < 30; i++) {
const allowed = await checkRateLimitWithCache(cache, 'user1', 30, 60);
expect(allowed).toBe(true);
}
// count=30인지 확인
const data = await cache.get<{ count: number; windowStart: number }>('user1');
expect(data?.count).toBe(30);
});
it('should block requests over limit', async () => {
// 30번 요청
for (let i = 0; i < 30; i++) {
await checkRateLimitWithCache(cache, 'user1', 30, 60);
}
// 31번째 요청은 차단
const allowed = await checkRateLimitWithCache(cache, 'user1', 30, 60);
expect(allowed).toBe(false);
});
it('should reset window after expiration', async () => {
// windowSeconds=1로 설정 (1초 윈도우)
await checkRateLimitWithCache(cache, 'user1', 30, 1);
// 2초 대기 (윈도우 만료)
await new Promise(resolve => setTimeout(resolve, 2000));
// 새 윈도우 시작되어야 함
const allowed = await checkRateLimitWithCache(cache, 'user1', 30, 1);
expect(allowed).toBe(true);
// count=1로 리셋되었는지 확인
const data = await cache.get<{ count: number; windowStart: number }>('user1');
expect(data?.count).toBe(1);
});
it('should handle different users independently', async () => {
// user1: 30번 요청
for (let i = 0; i < 30; i++) {
await checkRateLimitWithCache(cache, 'user1', 30, 60);
}
// user2: 아직 요청 없음 (허용되어야 함)
const allowed = await checkRateLimitWithCache(cache, 'user2', 30, 60);
expect(allowed).toBe(true);
});
it('should track count correctly', async () => {
await checkRateLimitWithCache(cache, 'user1', 30, 60); // count=1
await checkRateLimitWithCache(cache, 'user1', 30, 60); // count=2
await checkRateLimitWithCache(cache, 'user1', 30, 60); // count=3
const data = await cache.get<{ count: number; windowStart: number }>('user1');
expect(data?.count).toBe(3);
});
it('should handle concurrent requests', async () => {
// 동시에 10개의 요청
const promises = Array(10).fill(null).map(() =>
checkRateLimitWithCache(cache, 'user1', 30, 60)
);
const results = await Promise.all(promises);
// 모두 허용되어야 함 (10 <= 30)
expect(results.every(r => r)).toBe(true);
// count=10인지 확인 (race condition 가능성 있으나 테스트 환경에서는 순차 실행)
const data = await cache.get<{ count: number; windowStart: number }>('user1');
expect(data?.count).toBeGreaterThan(0);
expect(data?.count).toBeLessThanOrEqual(10);
});
});
});
describe('Session Cache', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = createSessionCache(mockKV as unknown as KVNamespace);
});
it('should use "session" prefix', async () => {
await cache.set('user1', { state: 'active' });
expect(mockKV.put).toHaveBeenCalledWith(
'session:user1',
expect.any(String),
undefined
);
});
it('should store and retrieve session data', async () => {
const sessionData = {
state: 'gathering',
collectedInfo: { purpose: '블로그' },
};
await cache.set('server_session:123', sessionData, 3600);
const result = await cache.get<typeof sessionData>('server_session:123');
expect(result).toEqual(sessionData);
});
});
describe('Prefix Isolation', () => {
let mockKV: ReturnType<typeof createMockKV>;
let rateCache: KVCache;
let sessionCache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
rateCache = createRateLimitCache(mockKV as unknown as KVNamespace);
sessionCache = createSessionCache(mockKV as unknown as KVNamespace);
});
it('should isolate keys between rate and session caches', async () => {
await rateCache.set('user1', 'rate-data');
await sessionCache.set('user1', 'session-data');
// rate:user1과 session:user1은 다른 키
const rateData = await rateCache.get<string>('user1');
const sessionData = await sessionCache.get<string>('user1');
expect(rateData).toBe('rate-data');
expect(sessionData).toBe('session-data');
});
it('should store keys with correct prefixes', async () => {
await rateCache.set('user1', 'test');
await sessionCache.set('user1', 'test');
expect(mockKV._store.has('rate:user1')).toBe(true);
expect(mockKV._store.has('session:user1')).toBe(true);
expect(mockKV._store.has('user1')).toBe(false); // prefix 없는 키는 없어야 함
});
});
describe('Complex Data Types', () => {
let mockKV: ReturnType<typeof createMockKV>;
let cache: KVCache;
beforeEach(() => {
mockKV = createMockKV();
cache = new KVCache(mockKV as unknown as KVNamespace, 'test');
});
it('should handle nested objects', async () => {
const data = {
user: {
id: 1,
profile: {
name: 'John',
age: 30,
},
},
};
await cache.set('complex', data);
const result = await cache.get<typeof data>('complex');
expect(result).toEqual(data);
});
it('should handle arrays', async () => {
const data = [1, 2, 3, 4, 5];
await cache.set('array', data);
const result = await cache.get<number[]>('array');
expect(result).toEqual(data);
});
it('should handle null values', async () => {
await cache.set('null-value', null);
const result = await cache.get('null-value');
expect(result).toBeNull();
});
it('should handle boolean values', async () => {
await cache.set('bool-true', true);
await cache.set('bool-false', false);
expect(await cache.get('bool-true')).toBe(true);
expect(await cache.get('bool-false')).toBe(false);
});
it('should handle number values', async () => {
await cache.set('number', 12345);
const result = await cache.get<number>('number');
expect(result).toBe(12345);
});
});