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>
499 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|