Files
cloud-server/scripts/e2e-tester.ts
kappa 1e750a863b refactor: 추천 시스템 제거
삭제된 파일:
- 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>
2026-01-26 00:35:59 +09:00

538 lines
17 KiB
TypeScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});