삭제된 파일: - src/routes/recommend.ts - src/services/recommendation.ts - src/services/recommendation.test.ts - src/services/stackConfig.ts - src/services/regionFilter.ts 수정된 파일: - src/index.ts: /recommend 라우트 제거 - src/routes/index.ts: handleRecommend export 제거 - src/constants.ts: RECOMMENDATIONS TTL, rate limit 제거 - src/middleware/rateLimit.ts: /recommend 설정 제거 - src/types.ts: 추천 관련 타입 제거 - scripts/e2e-tester.ts: recommend 시나리오 제거 - scripts/api-tester.ts: recommend 테스트 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
538 lines
17 KiB
TypeScript
Executable File
538 lines
17 KiB
TypeScript
Executable File
#!/usr/bin/env tsx
|
||
/**
|
||
* E2E Scenario Tester for Cloud Instances API
|
||
*
|
||
* Tests complete user workflows against the deployed API
|
||
*
|
||
* Requirements:
|
||
* API_KEY environment variable must be set
|
||
*
|
||
* Usage:
|
||
* export API_KEY=your-api-key-here
|
||
* npx tsx scripts/e2e-tester.ts [--scenario <name>] [--dry-run]
|
||
*
|
||
* Or use npm scripts:
|
||
* npm run test:e2e
|
||
* npm run test:e2e:dry
|
||
*/
|
||
|
||
import process from 'process';
|
||
|
||
// ============================================================
|
||
// Configuration
|
||
// ============================================================
|
||
|
||
const API_URL = process.env.API_URL || 'https://cloud-instances-api.kappa-d8e.workers.dev';
|
||
const API_KEY = process.env.API_KEY;
|
||
|
||
if (!API_KEY) {
|
||
console.error('\n❌ ERROR: API_KEY environment variable is required');
|
||
console.error('Please set API_KEY before running E2E tests:');
|
||
console.error(' export API_KEY=your-api-key-here');
|
||
console.error(' npm run test:e2e');
|
||
console.error('\nOr create a .env file (see .env.example for reference)');
|
||
process.exit(1);
|
||
}
|
||
|
||
interface TestContext {
|
||
recommendedInstanceId?: string;
|
||
linodeInstanceCount?: number;
|
||
tokyoInstances?: number;
|
||
seoulInstances?: number;
|
||
}
|
||
|
||
// ============================================================
|
||
// Utility Functions
|
||
// ============================================================
|
||
|
||
/**
|
||
* Make API request with proper headers and error handling
|
||
*/
|
||
async function apiRequest(
|
||
endpoint: string,
|
||
options: RequestInit = {}
|
||
): Promise<{ status: number; data: unknown; duration: number; headers: Headers }> {
|
||
const startTime = Date.now();
|
||
const url = `${API_URL}${endpoint}`;
|
||
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
'X-API-Key': API_KEY,
|
||
'Content-Type': 'application/json',
|
||
...options.headers,
|
||
},
|
||
});
|
||
|
||
const duration = Date.now() - startTime;
|
||
let data: unknown;
|
||
|
||
try {
|
||
const text = await response.text();
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch (err) {
|
||
data = { error: 'Failed to parse JSON response', rawText: text };
|
||
}
|
||
} catch (err) {
|
||
data = { error: 'Failed to read response body' };
|
||
}
|
||
|
||
return {
|
||
status: response.status,
|
||
data,
|
||
duration,
|
||
headers: response.headers,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Log step execution with consistent formatting
|
||
*/
|
||
function logStep(stepNum: number, message: string): void {
|
||
console.log(` Step ${stepNum}: ${message}`);
|
||
}
|
||
|
||
/**
|
||
* Log API response details
|
||
*/
|
||
function logResponse(method: string, endpoint: string, status: number, duration: number): void {
|
||
const statusIcon = status >= 200 && status < 300 ? '✅' : '❌';
|
||
console.log(` ${statusIcon} ${method} ${endpoint} - ${status} (${duration}ms)`);
|
||
}
|
||
|
||
/**
|
||
* Log validation result
|
||
*/
|
||
function logValidation(passed: boolean, message: string): void {
|
||
const icon = passed ? '✅' : '❌';
|
||
console.log(` ${icon} ${message}`);
|
||
}
|
||
|
||
/**
|
||
* Sleep utility for rate limiting tests
|
||
*/
|
||
function sleep(ms: number): Promise<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// ============================================================
|
||
// E2E Scenarios
|
||
// ============================================================
|
||
|
||
/**
|
||
* Scenario 1: Budget-Constrained Instance Search
|
||
*
|
||
* Flow:
|
||
* 1. GET /instances?max_price=50&sort_by=price&order=asc
|
||
* 2. Validate all results <= $50/month
|
||
* 3. Validate price sorting is correct
|
||
*/
|
||
async function scenario1Budget(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||
console.log('\n▶️ Scenario 1: Budget-Constrained Instance Search');
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY RUN] Would execute:');
|
||
console.log(' 1. GET /instances?max_price=50&sort_by=price&order=asc');
|
||
console.log(' 2. Validate all monthly_price <= $50');
|
||
console.log(' 3. Validate ascending price order');
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
// Step 1: Search within budget
|
||
logStep(1, 'Search instances under $50/month...');
|
||
const searchResp = await apiRequest('/instances?max_price=50&sort_by=price&order=asc&limit=20', {
|
||
method: 'GET',
|
||
});
|
||
|
||
logResponse('GET', '/instances', searchResp.status, searchResp.duration);
|
||
|
||
if (searchResp.status !== 200) {
|
||
console.log(` ❌ Expected 200, got ${searchResp.status}`);
|
||
return false;
|
||
}
|
||
|
||
const searchData = searchResp.data as {
|
||
success: boolean;
|
||
data?: {
|
||
instances?: Array<{ pricing: { monthly_price: number } }>;
|
||
};
|
||
};
|
||
|
||
const instances = searchData.data?.instances || [];
|
||
|
||
if (instances.length === 0) {
|
||
console.log(' ⚠️ No instances returned');
|
||
return true; // Not a failure, just empty result
|
||
}
|
||
|
||
// Step 2: Validate budget constraint
|
||
logStep(2, 'Validate all prices within budget...');
|
||
const withinBudget = instances.every((i) => i.pricing.monthly_price <= 50);
|
||
logValidation(withinBudget, `All ${instances.length} instances <= $50/month`);
|
||
|
||
// Step 3: Validate sorting
|
||
logStep(3, 'Validate price sorting (ascending)...');
|
||
let sortedCorrectly = true;
|
||
for (let i = 1; i < instances.length; i++) {
|
||
if (instances[i].pricing.monthly_price < instances[i - 1].pricing.monthly_price) {
|
||
sortedCorrectly = false;
|
||
break;
|
||
}
|
||
}
|
||
logValidation(sortedCorrectly, `Prices sorted in ascending order`);
|
||
|
||
const passed = withinBudget && sortedCorrectly;
|
||
console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${searchResp.duration}ms)`);
|
||
return passed;
|
||
} catch (error) {
|
||
console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Scenario 2: Cross-Region Price Comparison
|
||
*
|
||
* Flow:
|
||
* 1. GET /instances?region=ap-northeast-1 (Tokyo)
|
||
* 2. GET /instances?region=ap-northeast-2 (Seoul)
|
||
* 3. Compare average prices and instance counts
|
||
*/
|
||
async function scenario2RegionCompare(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||
console.log('\n▶️ Scenario 2: Cross-Region Price Comparison (Tokyo vs Seoul)');
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY RUN] Would execute:');
|
||
console.log(' 1. GET /instances?region=ap-northeast-1 (Tokyo)');
|
||
console.log(' 2. GET /instances?region=ap-northeast-2 (Seoul)');
|
||
console.log(' 3. Calculate average prices and compare');
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
// Step 1: Fetch Tokyo instances
|
||
logStep(1, 'Fetch Tokyo (ap-northeast-1) instances...');
|
||
const tokyoResp = await apiRequest('/instances?region=ap-northeast-1&limit=100', {
|
||
method: 'GET',
|
||
});
|
||
|
||
logResponse('GET', '/instances?region=ap-northeast-1', tokyoResp.status, tokyoResp.duration);
|
||
|
||
if (tokyoResp.status !== 200) {
|
||
console.log(` ❌ Expected 200, got ${tokyoResp.status}`);
|
||
return false;
|
||
}
|
||
|
||
const tokyoData = tokyoResp.data as {
|
||
success: boolean;
|
||
data?: {
|
||
instances?: Array<{ pricing: { monthly_price: number } }>;
|
||
pagination?: { total: number };
|
||
};
|
||
};
|
||
|
||
const tokyoInstances = tokyoData.data?.instances || [];
|
||
context.tokyoInstances = tokyoData.data?.pagination?.total || tokyoInstances.length;
|
||
|
||
// Step 2: Fetch Seoul instances
|
||
logStep(2, 'Fetch Seoul (ap-northeast-2) instances...');
|
||
const seoulResp = await apiRequest('/instances?region=ap-northeast-2&limit=100', {
|
||
method: 'GET',
|
||
});
|
||
|
||
logResponse('GET', '/instances?region=ap-northeast-2', seoulResp.status, seoulResp.duration);
|
||
|
||
if (seoulResp.status !== 200) {
|
||
console.log(` ❌ Expected 200, got ${seoulResp.status}`);
|
||
return false;
|
||
}
|
||
|
||
const seoulData = seoulResp.data as {
|
||
success: boolean;
|
||
data?: {
|
||
instances?: Array<{ pricing: { monthly_price: number } }>;
|
||
pagination?: { total: number };
|
||
};
|
||
};
|
||
|
||
const seoulInstances = seoulData.data?.instances || [];
|
||
context.seoulInstances = seoulData.data?.pagination?.total || seoulInstances.length;
|
||
|
||
// Step 3: Compare results
|
||
logStep(3, 'Compare regions...');
|
||
|
||
const tokyoAvg =
|
||
tokyoInstances.length > 0
|
||
? tokyoInstances.reduce((sum, i) => sum + i.pricing.monthly_price, 0) / tokyoInstances.length
|
||
: 0;
|
||
|
||
const seoulAvg =
|
||
seoulInstances.length > 0
|
||
? seoulInstances.reduce((sum, i) => sum + i.pricing.monthly_price, 0) / seoulInstances.length
|
||
: 0;
|
||
|
||
console.log(` Tokyo: ${tokyoInstances.length} instances, avg $${tokyoAvg.toFixed(2)}/month`);
|
||
console.log(` Seoul: ${seoulInstances.length} instances, avg $${seoulAvg.toFixed(2)}/month`);
|
||
|
||
if (tokyoAvg > 0 && seoulAvg > 0) {
|
||
const diff = ((tokyoAvg - seoulAvg) / seoulAvg) * 100;
|
||
console.log(` Price difference: ${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`);
|
||
}
|
||
|
||
const passed = tokyoInstances.length > 0 || seoulInstances.length > 0; // At least one region has data
|
||
console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${tokyoResp.duration + seoulResp.duration}ms)`);
|
||
return passed;
|
||
} catch (error) {
|
||
console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Scenario 3: Provider Sync and Data Verification
|
||
*
|
||
* Flow:
|
||
* 1. POST /sync with provider: linode
|
||
* 2. GET /health to check sync_status
|
||
* 3. GET /instances?provider=linode to verify data exists
|
||
*/
|
||
async function scenario3ProviderSync(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||
console.log('\n▶️ Scenario 3: Provider Sync and Data Verification');
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY RUN] Would execute:');
|
||
console.log(' 1. POST /sync with providers: ["linode"]');
|
||
console.log(' 2. GET /health to verify sync_status');
|
||
console.log(' 3. GET /instances?provider=linode to confirm data');
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
// Step 1: Trigger sync
|
||
logStep(1, 'Trigger Linode data sync...');
|
||
const syncResp = await apiRequest('/sync', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
providers: ['linode'],
|
||
}),
|
||
});
|
||
|
||
logResponse('POST', '/sync', syncResp.status, syncResp.duration);
|
||
|
||
if (syncResp.status !== 200) {
|
||
console.log(` ⚠️ Sync returned ${syncResp.status}, continuing...`);
|
||
}
|
||
|
||
const syncData = syncResp.data as {
|
||
success: boolean;
|
||
data?: {
|
||
providers?: Array<{ provider: string; success: boolean; instances_synced: number }>;
|
||
};
|
||
};
|
||
|
||
const linodeSync = syncData.data?.providers?.find((p) => p.provider === 'linode');
|
||
if (linodeSync) {
|
||
console.log(` Synced: ${linodeSync.instances_synced} instances`);
|
||
}
|
||
|
||
// Step 2: Check health
|
||
logStep(2, 'Verify sync status via /health...');
|
||
const healthResp = await apiRequest('/health', { method: 'GET' });
|
||
|
||
logResponse('GET', '/health', healthResp.status, healthResp.duration);
|
||
|
||
if (healthResp.status !== 200 && healthResp.status !== 503) {
|
||
console.log(` ❌ Unexpected status ${healthResp.status}`);
|
||
return false;
|
||
}
|
||
|
||
const healthData = healthResp.data as {
|
||
status: string;
|
||
components?: {
|
||
providers?: Array<{ name: string; sync_status: string; instances_count: number }>;
|
||
};
|
||
};
|
||
|
||
const linodeHealth = healthData.components?.providers?.find((p) => p.name === 'linode');
|
||
if (linodeHealth) {
|
||
console.log(` Status: ${linodeHealth.sync_status}, Instances: ${linodeHealth.instances_count}`);
|
||
}
|
||
|
||
// Step 3: Verify data exists
|
||
logStep(3, 'Verify Linode instances exist...');
|
||
const instancesResp = await apiRequest('/instances?provider=linode&limit=10', { method: 'GET' });
|
||
|
||
logResponse('GET', '/instances?provider=linode', instancesResp.status, instancesResp.duration);
|
||
|
||
if (instancesResp.status !== 200) {
|
||
console.log(` ❌ Expected 200, got ${instancesResp.status}`);
|
||
return false;
|
||
}
|
||
|
||
const instancesData = instancesResp.data as {
|
||
success: boolean;
|
||
data?: {
|
||
instances?: unknown[];
|
||
pagination?: { total: number };
|
||
};
|
||
};
|
||
|
||
const totalInstances = instancesData.data?.pagination?.total || 0;
|
||
context.linodeInstanceCount = totalInstances;
|
||
|
||
const hasData = totalInstances > 0;
|
||
logValidation(hasData, `Found ${totalInstances} Linode instances`);
|
||
|
||
const passed = hasData;
|
||
console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${syncResp.duration + healthResp.duration + instancesResp.duration}ms)`);
|
||
return passed;
|
||
} catch (error) {
|
||
console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Scenario 5: Rate Limiting Test
|
||
*
|
||
* Flow:
|
||
* 1. Send 10 rapid requests to /instances
|
||
* 2. Check for 429 Too Many Requests
|
||
* 3. Verify Retry-After header
|
||
*/
|
||
async function scenario5RateLimit(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||
console.log('\n▶️ Scenario 5: Rate Limiting Test');
|
||
|
||
if (dryRun) {
|
||
console.log(' [DRY RUN] Would execute:');
|
||
console.log(' 1. Send 10 rapid requests to /instances');
|
||
console.log(' 2. Check for 429 status code');
|
||
console.log(' 3. Verify Retry-After header presence');
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
logStep(1, 'Send 10 rapid requests...');
|
||
|
||
const requests: Promise<{ status: number; data: unknown; duration: number; headers: Headers }>[] = [];
|
||
for (let i = 0; i < 10; i++) {
|
||
requests.push(apiRequest('/instances?limit=1', { method: 'GET' }));
|
||
}
|
||
|
||
const responses = await Promise.all(requests);
|
||
|
||
const statuses = responses.map((r) => r.status);
|
||
const has429 = statuses.includes(429);
|
||
|
||
console.log(` Received statuses: ${statuses.join(', ')}`);
|
||
|
||
logStep(2, 'Check for rate limiting...');
|
||
if (has429) {
|
||
const rateLimitResp = responses.find((r) => r.status === 429);
|
||
const retryAfter = rateLimitResp?.headers.get('Retry-After');
|
||
|
||
logValidation(true, `Rate limit triggered (429)`);
|
||
logValidation(!!retryAfter, `Retry-After header: ${retryAfter || 'missing'}`);
|
||
|
||
console.log(` ✅ Scenario PASSED - Rate limiting is working`);
|
||
return true;
|
||
} else {
|
||
console.log(' ℹ️ No 429 responses (rate limit not triggered yet)');
|
||
console.log(` ✅ Scenario PASSED - All requests succeeded (limit not reached)`);
|
||
return true; // Not a failure, just means we didn't hit the limit
|
||
}
|
||
} catch (error) {
|
||
console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Main Execution
|
||
// ============================================================
|
||
|
||
interface ScenarioFunction {
|
||
(context: TestContext, dryRun: boolean): Promise<boolean>;
|
||
}
|
||
|
||
const scenarios: Record<string, ScenarioFunction> = {
|
||
budget: scenario1Budget,
|
||
region: scenario2RegionCompare,
|
||
sync: scenario3ProviderSync,
|
||
ratelimit: scenario5RateLimit,
|
||
};
|
||
|
||
async function main(): Promise<void> {
|
||
const args = process.argv.slice(2);
|
||
const scenarioFlag = args.findIndex((arg) => arg === '--scenario');
|
||
const dryRun = args.includes('--dry-run');
|
||
|
||
let selectedScenarios: [string, ScenarioFunction][];
|
||
|
||
if (scenarioFlag !== -1 && args[scenarioFlag + 1]) {
|
||
const scenarioName = args[scenarioFlag + 1];
|
||
const scenarioFn = scenarios[scenarioName];
|
||
|
||
if (!scenarioFn) {
|
||
console.error(`❌ Unknown scenario: ${scenarioName}`);
|
||
console.log('\nAvailable scenarios:');
|
||
Object.keys(scenarios).forEach((name) => console.log(` - ${name}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
selectedScenarios = [[scenarioName, scenarioFn]];
|
||
} else {
|
||
selectedScenarios = Object.entries(scenarios);
|
||
}
|
||
|
||
console.log('🎬 E2E Scenario Tester');
|
||
console.log('================================');
|
||
console.log(`API: ${API_URL}`);
|
||
if (dryRun) {
|
||
console.log('Mode: DRY RUN (no actual requests)');
|
||
}
|
||
console.log('');
|
||
|
||
const context: TestContext = {};
|
||
const results: Record<string, boolean> = {};
|
||
const startTime = Date.now();
|
||
|
||
for (const [name, fn] of selectedScenarios) {
|
||
try {
|
||
results[name] = await fn(context, dryRun);
|
||
// Add delay between scenarios to avoid rate limiting
|
||
if (!dryRun && selectedScenarios.length > 1) {
|
||
await sleep(1000);
|
||
}
|
||
} catch (error) {
|
||
console.error(`\n❌ Scenario ${name} crashed:`, error);
|
||
results[name] = false;
|
||
}
|
||
}
|
||
|
||
const totalDuration = Date.now() - startTime;
|
||
|
||
// Final report
|
||
console.log('\n================================');
|
||
console.log('📊 E2E Report');
|
||
console.log(` Scenarios: ${selectedScenarios.length}`);
|
||
console.log(` Passed: ${Object.values(results).filter((r) => r).length} ✅`);
|
||
console.log(` Failed: ${Object.values(results).filter((r) => !r).length} ❌`);
|
||
console.log(` Total Duration: ${(totalDuration / 1000).toFixed(1)}s`);
|
||
|
||
if (Object.values(results).some((r) => !r)) {
|
||
console.log('\n❌ Some scenarios failed');
|
||
process.exit(1);
|
||
} else {
|
||
console.log('\n✅ All scenarios passed');
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error('💥 Fatal error:', error);
|
||
process.exit(1);
|
||
});
|