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>
This commit is contained in:
kappa
2026-01-29 11:02:27 +09:00
parent f304c6a7d4
commit fbe696b88c
8 changed files with 832 additions and 56 deletions

View File

@@ -139,6 +139,44 @@ export const DEPOSIT_ACTION = {
REJECT: 'reject', REJECT: 'reject',
} as const; } 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 * 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 ServerAction = typeof SERVER_ACTION[keyof typeof SERVER_ACTION];
export type DomainAction = typeof DOMAIN_ACTION[keyof typeof DOMAIN_ACTION]; export type DomainAction = typeof DOMAIN_ACTION[keyof typeof DOMAIN_ACTION];
export type DepositAction = typeof DEPOSIT_ACTION[keyof typeof DEPOSIT_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];

View File

@@ -13,6 +13,7 @@ import type { Env, ServerSession, BandwidthInfo, RecommendResponse } from './typ
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
import { formatTrafficInfo } from './utils/formatters'; import { formatTrafficInfo } from './utils/formatters';
import { SERVER_CONSULTATION_STATUS, LANGUAGE_CODE } from './constants';
const logger = createLogger('server-agent'); const logger = createLogger('server-agent');
@@ -683,7 +684,7 @@ export async function processServerConsultation(
// 새 세션 생성하고 시작 메시지 반환 // 새 세션 생성하고 시작 메시지 반환
const newSession: ServerSession = { const newSession: ServerSession = {
telegramUserId: session.telegramUserId, telegramUserId: session.telegramUserId,
status: 'gathering', status: SERVER_CONSULTATION_STATUS.GATHERING,
collectedInfo: {}, collectedInfo: {},
messages: [], messages: [],
createdAt: Date.now(), createdAt: Date.now(),
@@ -699,10 +700,10 @@ export async function processServerConsultation(
status: session.status, status: session.status,
hasLastRecommendation: !!session.lastRecommendation, hasLastRecommendation: !!session.lastRecommendation,
recommendationCount: session.lastRecommendation?.recommendations?.length || 0, 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 상태에서만) // 상담과 무관한 키워드 감지 (selecting 상태에서만)
// 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환 // 명확히 다른 기능 요청인 경우 세션 종료하고 일반 처리로 전환
const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/; const unrelatedPatterns = /기억|날씨|계산|검색|도메인|입금|충전|잔액|시간|문서/;
@@ -812,7 +813,7 @@ export async function processServerConsultation(
} }
// Mark session as recommending // Mark session as recommending
session.status = 'recommending'; session.status = SERVER_CONSULTATION_STATUS.RECOMMENDING;
await saveServerSession(env.DB, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
// 1. Call recommendation API (추천 먼저 받기) // 1. Call recommendation API (추천 먼저 받기)
@@ -877,7 +878,7 @@ export async function processServerConsultation(
use_case: session.collectedInfo.useCase || '웹 서비스', use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: finalRegionPreference, region_preference: finalRegionPreference,
budget_limit: session.collectedInfo.budgetLimit, budget_limit: session.collectedInfo.budgetLimit,
lang: 'ko', lang: LANGUAGE_CODE.KOREAN,
}, },
env env
); );
@@ -927,14 +928,14 @@ export async function processServerConsultation(
use_case: session.collectedInfo.useCase || '웹 서비스', use_case: session.collectedInfo.useCase || '웹 서비스',
region_preference: session.collectedInfo.regionPreference, region_preference: session.collectedInfo.regionPreference,
budget_limit: session.collectedInfo.budgetLimit, budget_limit: session.collectedInfo.budgetLimit,
lang: 'ko', lang: LANGUAGE_CODE.KOREAN,
}, },
env, env,
session.telegramUserId session.telegramUserId
); );
// Mark session as selecting (사용자 선택 대기) // Mark session as selecting (사용자 선택 대기)
session.status = 'selecting'; session.status = SERVER_CONSULTATION_STATUS.SELECTING;
await saveServerSession(env.DB, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
// 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에) // 4. 추천 결과 + AI 검토 코멘트 (검토 코멘트는 마지막에)
@@ -942,14 +943,14 @@ export async function processServerConsultation(
return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`; return `${formattedRecommendation}\n\n💬 ${reviewResult.message}\n\n💡 원하는 서버 번호를 선택해주세요 (예: 1번)`;
} else { } else {
// 추천 결과 없음 - 세션 삭제 // 추천 결과 없음 - 세션 삭제
session.status = 'completed'; session.status = SERVER_CONSULTATION_STATUS.COMPLETED;
await deleteServerSession(env.DB, session.telegramUserId); await deleteServerSession(env.DB, session.telegramUserId);
return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`; return `${aiResult.message}\n\n조건에 맞는 서버를 찾지 못했습니다.`;
} }
} else { } else {
// Continue gathering information // Continue gathering information
session.status = 'gathering'; session.status = SERVER_CONSULTATION_STATUS.GATHERING;
await saveServerSession(env.DB, session.telegramUserId, session); await saveServerSession(env.DB, session.telegramUserId, session);
return aiResult.message; return aiResult.message;

View File

@@ -1,13 +1,15 @@
import { Env } from '../types'; import { Env } from '../types';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { NOTIFICATION_TYPE } from '../constants';
import type { NotificationType } from '../constants';
const logger = createLogger('notification'); const logger = createLogger('notification');
/** /**
* 알림 유형별 메시지 템플릿 * 알림 유형별 메시지 템플릿
*/ */
const NOTIFICATION_TEMPLATES = { const NOTIFICATION_TEMPLATES: Record<NotificationType, (details: NotificationDetails) => string> = {
circuit_breaker: (details: NotificationDetails) => ` [NOTIFICATION_TYPE.CIRCUIT_BREAKER]: (details: NotificationDetails) => `
🚨 시스템 알림 (Circuit Breaker) 🚨 시스템 알림 (Circuit Breaker)
서비스: ${details.service} 서비스: ${details.service}
@@ -19,7 +21,7 @@ const NOTIFICATION_TEMPLATES = {
${details.context ? `\n추가 정보:\n${details.context}` : ''} ${details.context ? `\n추가 정보:\n${details.context}` : ''}
`.trim(), `.trim(),
retry_exhausted: (details: NotificationDetails) => ` [NOTIFICATION_TYPE.RETRY_EXHAUSTED]: (details: NotificationDetails) => `
⚠️ 시스템 알림 (재시도 실패) ⚠️ 시스템 알림 (재시도 실패)
서비스: ${details.service} 서비스: ${details.service}
@@ -31,7 +33,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''}
${details.context ? `\n추가 정보:\n${details.context}` : ''} ${details.context ? `\n추가 정보:\n${details.context}` : ''}
`.trim(), `.trim(),
api_error: (details: NotificationDetails) => ` [NOTIFICATION_TYPE.API_ERROR]: (details: NotificationDetails) => `
🔴 시스템 알림 (API 에러) 🔴 시스템 알림 (API 에러)
서비스: ${details.service} 서비스: ${details.service}
@@ -42,12 +44,7 @@ ${details.context ? `\n추가 정보:\n${details.context}` : ''}
즉시 확인이 필요합니다. 즉시 확인이 필요합니다.
${details.context ? `\n추가 정보:\n${details.context}` : ''} ${details.context ? `\n추가 정보:\n${details.context}` : ''}
`.trim(), `.trim(),
} as const; };
/**
* 알림 유형
*/
export type NotificationType = keyof typeof NOTIFICATION_TEMPLATES;
/** /**
* 알림 상세 정보 * 알림 상세 정보

View File

@@ -1,11 +1,8 @@
import type { import type {
Env, Env,
OpenAIResponse, OpenAIResponse
BraveSearchResponse,
BraveSearchResult,
Context7SearchResponse,
Context7DocsResponse
} from '../types'; } from '../types';
import { z } from 'zod';
import { retryWithBackoff, RetryError } from '../utils/retry'; import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger } from '../utils/logger'; import { createLogger } from '../utils/logger';
import { getOpenAIUrl } from '../utils/api-urls'; import { getOpenAIUrl } from '../utils/api-urls';
@@ -13,6 +10,39 @@ import { ERROR_MESSAGES } from '../constants/messages';
const logger = createLogger('search-tool'); 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 = { export const searchWebTool = {
type: 'function', type: 'function',
function: { function: {
@@ -143,7 +173,20 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
if (!response.ok) { if (!response.ok) {
return `🔍 검색 오류: ${response.status}`; 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 검색 결과 파싱 // Web 검색 결과 파싱
const webResults = data.web?.results || []; const webResults = data.web?.results || [];
@@ -151,8 +194,8 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
return `🔍 "${query}"에 대한 검색 결과가 없습니다.`; return `🔍 "${query}"에 대한 검색 결과가 없습니다.`;
} }
const results = webResults.slice(0, 3).map((r: BraveSearchResult, i: number) => const results = webResults.slice(0, 3).map((r, i: number) =>
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}` `${i + 1}. <b>${r.title}</b>\n ${r.description || '설명 없음'}\n ${r.url}`
).join('\n\n'); ).join('\n\n');
// 번역된 경우 원본 쿼리도 표시 // 번역된 경우 원본 쿼리도 표시
@@ -192,13 +235,27 @@ export async function executeLookupDocs(args: { library: string; query: string }
() => fetch(searchUrl), () => fetch(searchUrl),
{ maxRetries: 3 } { 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}" 라이브러리를 찾을 수 없습니다.`; return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`;
} }
const libraryId = searchData.libraries[0].id; const libraryId = libraries[0].id;
// 3. 문서 조회 // 3. 문서 조회
const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`; 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), () => fetch(docsUrl),
{ maxRetries: 3 } { 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) { if (docsData.error) {
return `📚 문서 조회 실패: ${docsData.message || docsData.error}`; return `📚 문서 조회 실패: ${docsData.message || docsData.error}`;

View File

@@ -17,6 +17,7 @@
import type { Env, TroubleshootSession } from './types'; import type { Env, TroubleshootSession } from './types';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { executeSearchWeb, executeLookupDocs } from './tools/search-tool'; import { executeSearchWeb, executeLookupDocs } from './tools/search-tool';
import { TROUBLESHOOT_STATUS } from './constants';
const logger = createLogger('troubleshoot-agent'); const logger = createLogger('troubleshoot-agent');
@@ -435,11 +436,11 @@ export async function processTroubleshoot(
// Update session status based on action // Update session status based on action
if (aiResult.action === 'diagnose') { if (aiResult.action === 'diagnose') {
session.status = 'diagnosing'; session.status = TROUBLESHOOT_STATUS.DIAGNOSING;
} else if (aiResult.action === 'solve') { } else if (aiResult.action === 'solve') {
session.status = 'solving'; session.status = TROUBLESHOOT_STATUS.SOLVING;
} else { } else {
session.status = 'gathering'; session.status = TROUBLESHOOT_STATUS.GATHERING;
} }
await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session); await saveTroubleshootSession(env.SESSION_KV, session.telegramUserId, session);

157
src/utils/api-helper.ts Normal file
View File

@@ -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<T> {
/** HTTP 메서드 (기본값: 'GET') */
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
/** HTTP 헤더 (기본 헤더와 병합됨) */
headers?: Record<string, string>;
/** 요청 본문 (JSON으로 자동 직렬화) */
body?: Record<string, unknown>;
/** 타임아웃 (밀리초, 기본값: 30000) */
timeout?: number;
/** 재시도 횟수 (기본값: 3) */
retries?: number;
/** 응답 검증 스키마 (Zod) */
schema?: z.ZodType<T>;
}
/**
* 외부 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<T>(
url: string,
options?: ApiOptions<T>
): Promise<T> {
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,
});
}

498
tests/kv-cache.test.ts Normal file
View File

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

View File

@@ -14,10 +14,12 @@ declare global {
}; };
} }
let db: D1Database; let db: D1Database | null = null;
beforeAll(async () => { beforeAll(async () => {
// Miniflare 바인딩 가져오기 // Miniflare 바인딩 가져오기 (있을 경우만)
if (typeof getMiniflareBindings === 'function') {
try {
const bindings = getMiniflareBindings(); const bindings = getMiniflareBindings();
db = bindings.DB; db = bindings.DB;
@@ -34,9 +36,16 @@ beforeAll(async () => {
for (const statement of statements) { for (const statement of statements) {
await db.exec(statement); await db.exec(statement);
} }
} catch (error) {
// Miniflare 바인딩이 없는 테스트는 skip
console.warn('Miniflare bindings not available, skipping DB setup');
}
}
}); });
afterEach(async () => { afterEach(async () => {
// DB가 있을 경우만 정리
if (db) {
// 각 테스트 후 데이터 정리 (스키마는 유지) // 각 테스트 후 데이터 정리 (스키마는 유지)
await db.exec('DELETE FROM deposit_transactions'); await db.exec('DELETE FROM deposit_transactions');
await db.exec('DELETE FROM bank_notifications'); await db.exec('DELETE FROM bank_notifications');
@@ -45,6 +54,7 @@ afterEach(async () => {
await db.exec('DELETE FROM summaries'); await db.exec('DELETE FROM summaries');
await db.exec('DELETE FROM message_buffer'); await db.exec('DELETE FROM message_buffer');
await db.exec('DELETE FROM users'); await db.exec('DELETE FROM users');
}
}); });
/** /**