/** * 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(); 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; 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('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('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('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; 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('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; 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; 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; 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('server_session:123'); expect(result).toEqual(sessionData); }); }); describe('Prefix Isolation', () => { let mockKV: ReturnType; 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('user1'); const sessionData = await sessionCache.get('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; 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('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('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'); expect(result).toBe(12345); }); });