diff --git a/src/constants/index.ts b/src/constants/index.ts index 894ff4b..e706b1f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -139,6 +139,44 @@ export const DEPOSIT_ACTION = { REJECT: 'reject', } as const; +/** + * Notification types for admin alerts + */ +export const NOTIFICATION_TYPE = { + CIRCUIT_BREAKER: 'circuit_breaker', + RETRY_EXHAUSTED: 'retry_exhausted', + API_ERROR: 'api_error', +} as const; + +/** + * Troubleshoot session statuses + */ +export const TROUBLESHOOT_STATUS = { + GATHERING: 'gathering', + DIAGNOSING: 'diagnosing', + SOLVING: 'solving', +} as const; + +/** + * Server consultation session statuses + */ +export const SERVER_CONSULTATION_STATUS = { + GATHERING: 'gathering', + RECOMMENDING: 'recommending', + SELECTING: 'selecting', + COMPLETED: 'completed', +} as const; + +/** + * Language codes for multi-language support + */ +export const LANGUAGE_CODE = { + KOREAN: 'ko', + JAPANESE: 'ja', + CHINESE: 'zh', + ENGLISH: 'en', +} as const; + /** * Helper to create session key with user ID * @@ -183,3 +221,7 @@ export type ServerOrderStatus = typeof SERVER_ORDER_STATUS[keyof typeof SERVER_O export type ServerAction = typeof SERVER_ACTION[keyof typeof SERVER_ACTION]; export type DomainAction = typeof DOMAIN_ACTION[keyof typeof DOMAIN_ACTION]; export type DepositAction = typeof DEPOSIT_ACTION[keyof typeof DEPOSIT_ACTION]; +export type NotificationType = typeof NOTIFICATION_TYPE[keyof typeof NOTIFICATION_TYPE]; +export type TroubleshootStatus = typeof TROUBLESHOOT_STATUS[keyof typeof TROUBLESHOOT_STATUS]; +export type ServerConsultationStatus = typeof SERVER_CONSULTATION_STATUS[keyof typeof SERVER_CONSULTATION_STATUS]; +export type LanguageCode = typeof LANGUAGE_CODE[keyof typeof LANGUAGE_CODE]; diff --git a/src/server-agent.ts b/src/server-agent.ts index d6e243b..dbe6a4e 100644 --- a/src/server-agent.ts +++ b/src/server-agent.ts @@ -13,6 +13,7 @@ import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './typ import { createLogger } from './utils/logger'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; import { formatTrafficInfo } from './utils/formatters'; +import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from './constants'; const logger = createLogger('server-agent'); @@ -683,7 +684,7 @@ export async function processServerConsultation( // 새 세션 생성하고 시작 메시지 반환 const newSession: ServerSession = { telegramUserId: session.telegramUserId, - status: 'gathering', + status: SERVER_CONSULTATION_STATUS.GATHERING, collectedInfo: {}, messages: [], createdAt: Date.now(), @@ -699,10 +700,10 @@ export async function processServerConsultation( status: session.status, hasLastRecommendation: !!session.lastRecommendation, recommendationCount: session.lastRecommendation?.recommendations?.length || 0, - willProcessSelection: session.status === 'selecting' && !!session.lastRecommendation + willProcessSelection: session.status === SERVER_CONSULTATION_STATUS.SELECTING && !!session.lastRecommendation }); - if (session.status === 'selecting' && session.lastRecommendation) { + if (session.status === SERVER_CONSULTATION_STATUS.SELECTING && session.lastRecommendation) { // 상담과 무관한 키워드 감지 (selecting 상태에서만) // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; @@ -812,7 +813,7 @@ export async function processServerConsultation( } // Mark session as recommending - session.status = 'recommending'; + session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING; await saveServerSession(env.DB, session.telegramUserId, session); // 1. Call recommendation API (추천 먼저 받기) @@ -877,7 +878,7 @@ export async function processServerConsultation( use_case: session.collectedInfo.useCase || '웹 서비스', region_preference: finalRegionPreference, budget_limit: session.collectedInfo.budgetLimit, - lang: 'ko', + lang: LANGUAGE_CODE.KOREAN, }, env ); @@ -927,14 +928,14 @@ export async function processServerConsultation( use_case: session.collectedInfo.useCase || '웹 서비스', region_preference: session.collectedInfo.regionPreference, budget_limit: session.collectedInfo.budgetLimit, - lang: 'ko', + lang: LANGUAGE_CODE.KOREAN, }, env, session.telegramUserId ); // Mark session as selecting (사용자 선택 대기) - session.status = 'selecting'; + session.status = SERVER_CONSULTATION_STATUS.SELECTING; await saveServerSession(env.DB, session.telegramUserId, session); // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) @@ -942,14 +943,14 @@ export async function processServerConsultation( return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; } else { // 추천 결과 없음 - 세션 삭제 - session.status = 'completed'; + session.status = SERVER_CONSULTATION_STATUS.COMPLETED; await deleteServerSession(env.DB, session.telegramUserId); return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; } } else { // Continue gathering information - session.status = 'gathering'; + session.status = SERVER_CONSULTATION_STATUS.GATHERING; await saveServerSession(env.DB, session.telegramUserId, session); return aiResult.message; diff --git a/src/services/notification.ts b/src/services/notification.ts index a0c4be7..88e96a3 100644 --- a/src/services/notification.ts +++ b/src/services/notification.ts @@ -1,13 +1,15 @@ import { Env } from '../types'; import { createLogger } from '../utils/logger'; +import { NOTIFICATION_TYPE } from '../constants'; +import type { NotificationType } from '../constants'; const logger = createLogger('notification'); /** * 알림 유형별 메시지 템플릿 */ -const NOTIFICATION_TEMPLATES = { - circuit_breaker: (details: NotificationDetails) => ` +const NOTIFICATION_TEMPLATES: Record string> = { + [NOTIFICATION_TYPE.CIRCUIT_BREAKER]: (details: NotificationDetails) => ` 🚨 시스템 알림 (Circuit Breaker) 서비스: ${details.service} @@ -19,7 +21,7 @@ const NOTIFICATION_TEMPLATES = { ${details.context ? `\n추가 정보:\n${details.context}` : ''} `.trim(), - retry_exhausted: (details: NotificationDetails) => ` + [NOTIFICATION_TYPE.RETRY_EXHAUSTED]: (details: NotificationDetails) => ` ⚠️ 시스템 알림 (재시도 실패) 서비스: ${details.service} @@ -31,7 +33,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''} ${details.context ? `\n추가 정보:\n${details.context}` : ''} `.trim(), - api_error: (details: NotificationDetails) => ` + [NOTIFICATION_TYPE.API_ERROR]: (details: NotificationDetails) => ` 🔴 시스템 알림 (API 에러) 서비스: ${details.service} @@ -42,12 +44,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''} 즉시 확인이 필요합니다. ${details.context ? `\n추가 정보:\n${details.context}` : ''} `.trim(), -} as const; - -/** - * 알림 유형 - */ -export type NotificationType = keyof typeof NOTIFICATION_TEMPLATES; +}; /** * 알림 상세 정보 diff --git a/src/tools/search-tool.ts b/src/tools/search-tool.ts index c732331..67de9c4 100644 --- a/src/tools/search-tool.ts +++ b/src/tools/search-tool.ts @@ -1,11 +1,8 @@ import type { Env, - OpenAIResponse, - BraveSearchResponse, - BraveSearchResult, - Context7SearchResponse, - Context7DocsResponse + OpenAIResponse } from '../types'; +import { z } from 'zod'; import { retryWithBackoff, RetryError } from '../utils/retry'; import { createLogger } from '../utils/logger'; import { getOpenAIUrl } from '../utils/api-urls'; @@ -13,6 +10,39 @@ import { ERROR_MESSAGES } from '../constants/messages'; const logger = createLogger('search-tool'); +// Zod schemas for API response validation +const BraveSearchResultSchema = z.object({ + title: z.string(), + url: z.string(), + description: z.string().optional(), +}); + +const BraveSearchResponseSchema = z.object({ + web: z.object({ + results: z.array(BraveSearchResultSchema), + }).optional(), + query: z.object({ + original: z.string(), + }).optional(), +}); + +const Context7LibrarySchema = z.object({ + id: z.string(), + name: z.string().optional(), +}); + +const Context7SearchResponseSchema = z.object({ + libraries: z.array(Context7LibrarySchema).optional(), + results: z.array(Context7LibrarySchema).optional(), // API가 results로 반환 +}); + +const Context7DocsResponseSchema = z.object({ + context: z.string().optional(), + content: z.string().optional(), + message: z.string().optional(), + error: z.string().optional(), +}); + export const searchWebTool = { type: 'function', function: { @@ -143,7 +173,20 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom if (!response.ok) { return `🔍 검색 오류: ${response.status}`; } - const data = await response.json() as BraveSearchResponse; + + // Zod를 사용한 응답 검증 + const json = await response.json(); + const parsed = BraveSearchResponseSchema.safeParse(json); + + if (!parsed.success) { + logger.warn('Brave API 응답 파싱 실패', { + error: parsed.error.issues, + receivedData: JSON.stringify(json).substring(0, 200) + }); + return `🔍 검색 결과 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`; + } + + const data = parsed.data; // Web 검색 결과 파싱 const webResults = data.web?.results || []; @@ -151,8 +194,8 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom return `🔍 "${query}"에 대한 검색 결과가 없습니다.`; } - const results = webResults.slice(0, 3).map((r: BraveSearchResult, i: number) => - `${i + 1}. ${r.title}\n ${r.description}\n ${r.url}` + const results = webResults.slice(0, 3).map((r, i: number) => + `${i + 1}. ${r.title}\n ${r.description || '설명 없음'}\n ${r.url}` ).join('\n\n'); // 번역된 경우 원본 쿼리도 표시 @@ -192,13 +235,27 @@ export async function executeLookupDocs(args: { library: string; query: string } () => fetch(searchUrl), { maxRetries: 3 } ); - const searchData = await searchResponse.json() as Context7SearchResponse; - if (!searchData.libraries?.length) { + // Zod를 사용한 검색 응답 검증 + const searchJson = await searchResponse.json(); + const searchParsed = Context7SearchResponseSchema.safeParse(searchJson); + + if (!searchParsed.success) { + logger.warn('Context7 검색 API 응답 파싱 실패', { + error: searchParsed.error.issues, + receivedData: JSON.stringify(searchJson).substring(0, 200) + }); + return `📚 라이브러리 검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`; + } + + const searchData = searchParsed.data; + const libraries = searchData.libraries || searchData.results || []; + + if (libraries.length === 0) { return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`; } - const libraryId = searchData.libraries[0].id; + const libraryId = libraries[0].id; // 3. 문서 조회 const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`; @@ -206,7 +263,20 @@ export async function executeLookupDocs(args: { library: string; query: string } () => fetch(docsUrl), { maxRetries: 3 } ); - const docsData = await docsResponse.json() as Context7DocsResponse; + + // Zod를 사용한 문서 응답 검증 + const docsJson = await docsResponse.json(); + const docsParsed = Context7DocsResponseSchema.safeParse(docsJson); + + if (!docsParsed.success) { + logger.warn('Context7 문서 API 응답 파싱 실패', { + error: docsParsed.error.issues, + receivedData: JSON.stringify(docsJson).substring(0, 200) + }); + return `📚 문서 조회 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.`; + } + + const docsData = docsParsed.data; if (docsData.error) { return `📚 문서 조회 실패: ${docsData.message || docsData.error}`; diff --git a/src/troubleshoot-agent.ts b/src/troubleshoot-agent.ts index f7ee4b6..3cbca17 100644 --- a/src/troubleshoot-agent.ts +++ b/src/troubleshoot-agent.ts @@ -17,6 +17,7 @@ import type { Env, TroubleshootSession } from './types'; import { createLogger } from './utils/logger'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; +import { TROUBLESHOOT_STATUS } from './constants'; const logger = createLogger('troubleshoot-agent'); @@ -435,11 +436,11 @@ export async function processTroubleshoot( // Update session status based on action if (aiResult.action === 'diagnose') { - session.status = 'diagnosing'; + session.status = TROUBLESHOOT_STATUS.DIAGNOSING; } else if (aiResult.action === 'solve') { - session.status = 'solving'; + session.status = TROUBLESHOOT_STATUS.SOLVING; } else { - session.status = 'gathering'; + session.status = TROUBLESHOOT_STATUS.GATHERING; } await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session); diff --git a/src/utils/api-helper.ts b/src/utils/api-helper.ts new file mode 100644 index 0000000..55bfc93 --- /dev/null +++ b/src/utils/api-helper.ts @@ -0,0 +1,157 @@ +/** + * API 호출 추상화 유틸리티 + * + * Cloudflare Workers 환경에서 외부 API 호출을 위한 추상화 레이어입니다. + * 재시도 로직, 타임아웃, 응답 검증을 자동으로 처리합니다. + * + * @module api-helper + * @example + * ```typescript + * import { callApi } from './utils/api-helper'; + * import { z } from 'zod'; + * + * // 스키마 정의 + * const UserSchema = z.object({ + * id: z.number(), + * name: z.string(), + * }); + * + * // API 호출 (자동 검증) + * const user = await callApi('https://api.example.com/user', { + * schema: UserSchema, + * }); + * ``` + */ + +import { z } from 'zod'; +import { createLogger } from './logger'; +import { retryWithBackoff } from './retry'; + +const logger = createLogger('api-helper'); + +/** + * API 호출 옵션 인터페이스 + * + * @template T - 응답 타입 + */ +export interface ApiOptions { + /** HTTP 메서드 (기본값: 'GET') */ + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + /** HTTP 헤더 (기본 헤더와 병합됨) */ + headers?: Record; + /** 요청 본문 (JSON으로 자동 직렬화) */ + body?: Record; + /** 타임아웃 (밀리초, 기본값: 30000) */ + timeout?: number; + /** 재시도 횟수 (기본값: 3) */ + retries?: number; + /** 응답 검증 스키마 (Zod) */ + schema?: z.ZodType; +} + +/** + * 외부 API 호출 추상화 함수 + * + * 다음 기능을 자동으로 처리합니다: + * - 재시도 로직 (지수 백오프 + 지터) + * - 타임아웃 제어 + * - JSON 응답 파싱 + * - 응답 검증 (Zod 스키마) + * - 구조화된 로깅 + * + * @template T - 응답 타입 + * @param url - 호출할 API URL + * @param options - API 호출 옵션 + * @returns 파싱 및 검증된 응답 데이터 + * @throws Error if API call fails after retries + * + * @example + * ```typescript + * // 기본 GET 요청 + * const data = await callApi<{ status: string }>('https://api.example.com/health'); + * + * // POST 요청 with body + * const result = await callApi('https://api.example.com/data', { + * method: 'POST', + * body: { name: 'test' }, + * }); + * + * // 스키마 검증 + * const ResponseSchema = z.object({ + * success: z.boolean(), + * data: z.array(z.string()), + * }); + * + * const validated = await callApi('https://api.example.com/list', { + * schema: ResponseSchema, + * }); + * + * // 커스텀 타임아웃 및 재시도 + * const important = await callApi('https://api.example.com/critical', { + * timeout: 60000, // 60초 + * retries: 5, // 5회 재시도 + * }); + * ``` + */ +export async function callApi( + url: string, + options?: ApiOptions +): Promise { + return retryWithBackoff(async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 30000); + + try { + logger.debug('API 호출 시작', { + url, + method: options?.method || 'GET', + timeout: options?.timeout || 30000, + }); + + const response = await fetch(url, { + method: options?.method || 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + ...options?.headers, + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + logger.error('API 호출 실패', undefined, { + url, + status: response.status, + statusText: response.statusText, + }); + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + const json = await response.json(); + + if (options?.schema) { + try { + const validated = options.schema.parse(json); + logger.debug('API 응답 검증 성공', { url }); + return validated; + } catch (error) { + logger.error('API 응답 검증 실패', error as Error, { + url, + response: json, + }); + throw error; + } + } + + logger.debug('API 호출 성공', { url }); + return json as T; + } finally { + clearTimeout(timeoutId); + } + }, { + maxRetries: options?.retries || 3, + initialDelayMs: 1000, + maxDelayMs: 10000, + }); +} diff --git a/tests/kv-cache.test.ts b/tests/kv-cache.test.ts new file mode 100644 index 0000000..fe48caa --- /dev/null +++ b/tests/kv-cache.test.ts @@ -0,0 +1,498 @@ +/** + * 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); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a301fdb..9db09d4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -14,37 +14,47 @@ declare global { }; } -let db: D1Database; +let db: D1Database | null = null; beforeAll(async () => { - // Miniflare 바인딩 가져오기 - const bindings = getMiniflareBindings(); - db = bindings.DB; + // Miniflare 바인딩 가져오기 (있을 경우만) + if (typeof getMiniflareBindings === 'function') { + try { + const bindings = getMiniflareBindings(); + db = bindings.DB; - // 스키마 초기화 - const schemaPath = join(__dirname, '../schema.sql'); - const schema = readFileSync(schemaPath, 'utf-8'); + // 스키마 초기화 + const schemaPath = join(__dirname, '../schema.sql'); + const schema = readFileSync(schemaPath, 'utf-8'); - // 각 statement를 개별 실행 - const statements = schema - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0 && !s.startsWith('--')); + // 각 statement를 개별 실행 + const statements = schema + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith('--')); - for (const statement of statements) { - await db.exec(statement); + for (const statement of statements) { + await db.exec(statement); + } + } catch (error) { + // Miniflare 바인딩이 없는 테스트는 skip + console.warn('Miniflare bindings not available, skipping DB setup'); + } } }); afterEach(async () => { - // 각 테스트 후 데이터 정리 (스키마는 유지) - await db.exec('DELETE FROM deposit_transactions'); - await db.exec('DELETE FROM bank_notifications'); - await db.exec('DELETE FROM user_deposits'); - await db.exec('DELETE FROM user_domains'); - await db.exec('DELETE FROM summaries'); - await db.exec('DELETE FROM message_buffer'); - await db.exec('DELETE FROM users'); + // DB가 있을 경우만 정리 + if (db) { + // 각 테스트 후 데이터 정리 (스키마는 유지) + await db.exec('DELETE FROM deposit_transactions'); + await db.exec('DELETE FROM bank_notifications'); + await db.exec('DELETE FROM user_deposits'); + await db.exec('DELETE FROM user_domains'); + await db.exec('DELETE FROM summaries'); + await db.exec('DELETE FROM message_buffer'); + await db.exec('DELETE FROM users'); + } }); /**