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:
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 상세 정보
|
* 알림 상세 정보
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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
157
src/utils/api-helper.ts
Normal 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
498
tests/kv-cache.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,37 +14,47 @@ declare global {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let db: D1Database;
|
let db: D1Database | null = null;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Miniflare 바인딩 가져오기
|
// Miniflare 바인딩 가져오기 (있을 경우만)
|
||||||
const bindings = getMiniflareBindings();
|
if (typeof getMiniflareBindings === 'function') {
|
||||||
db = bindings.DB;
|
try {
|
||||||
|
const bindings = getMiniflareBindings();
|
||||||
|
db = bindings.DB;
|
||||||
|
|
||||||
// 스키마 초기화
|
// 스키마 초기화
|
||||||
const schemaPath = join(__dirname, '../schema.sql');
|
const schemaPath = join(__dirname, '../schema.sql');
|
||||||
const schema = readFileSync(schemaPath, 'utf-8');
|
const schema = readFileSync(schemaPath, 'utf-8');
|
||||||
|
|
||||||
// 각 statement를 개별 실행
|
// 각 statement를 개별 실행
|
||||||
const statements = schema
|
const statements = schema
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(s => s.length > 0 && !s.startsWith('--'));
|
.filter(s => s.length > 0 && !s.startsWith('--'));
|
||||||
|
|
||||||
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가 있을 경우만 정리
|
||||||
await db.exec('DELETE FROM deposit_transactions');
|
if (db) {
|
||||||
await db.exec('DELETE FROM bank_notifications');
|
// 각 테스트 후 데이터 정리 (스키마는 유지)
|
||||||
await db.exec('DELETE FROM user_deposits');
|
await db.exec('DELETE FROM deposit_transactions');
|
||||||
await db.exec('DELETE FROM user_domains');
|
await db.exec('DELETE FROM bank_notifications');
|
||||||
await db.exec('DELETE FROM summaries');
|
await db.exec('DELETE FROM user_deposits');
|
||||||
await db.exec('DELETE FROM message_buffer');
|
await db.exec('DELETE FROM user_domains');
|
||||||
await db.exec('DELETE FROM users');
|
await db.exec('DELETE FROM summaries');
|
||||||
|
await db.exec('DELETE FROM message_buffer');
|
||||||
|
await db.exec('DELETE FROM users');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user