diff --git a/app/Http/Controllers/DevTools/FlowTesterController.php b/app/Http/Controllers/DevTools/FlowTesterController.php index c0a4dcca..68bef09c 100644 --- a/app/Http/Controllers/DevTools/FlowTesterController.php +++ b/app/Http/Controllers/DevTools/FlowTesterController.php @@ -22,7 +22,7 @@ class FlowTesterController extends Controller public function index(): View { $flows = AdminApiFlow::with(['runs' => fn ($q) => $q->latest()->limit(1)]) - ->orderByDesc('updated_at') + ->orderByDesc('created_at') ->paginate(20); return view('dev-tools.flow-tester.index', compact('flows')); @@ -231,6 +231,7 @@ public function run(int $id) 'failed_step' => $result['failedStep'], 'execution_log' => $result['executionLog'], 'error_message' => $result['errorMessage'], + 'api_logs' => $result['apiLogs'] ?? [], ]); return response()->json([ diff --git a/app/Models/Admin/AdminApiFlowRun.php b/app/Models/Admin/AdminApiFlowRun.php index 5aab8b20..9efe3a84 100644 --- a/app/Models/Admin/AdminApiFlowRun.php +++ b/app/Models/Admin/AdminApiFlowRun.php @@ -53,6 +53,7 @@ class AdminApiFlowRun extends Model 'execution_log', 'input_variables', 'error_message', + 'api_logs', 'executed_by', ]; @@ -67,6 +68,7 @@ class AdminApiFlowRun extends Model 'failed_step' => 'integer', 'execution_log' => 'array', 'input_variables' => 'array', + 'api_logs' => 'array', 'executed_by' => 'integer', ]; @@ -171,4 +173,33 @@ public function getStatusColorAttribute(): string default => 'bg-gray-100 text-gray-600', }; } + + /** + * API 로그 개수 반환 + */ + public function getApiLogCountAttribute(): int + { + return is_array($this->api_logs) ? count($this->api_logs) : 0; + } + + /** + * API 로그에 오류가 있는지 확인 + */ + public function hasApiErrors(): bool + { + if (! is_array($this->api_logs)) { + return false; + } + + foreach ($this->api_logs as $log) { + if (isset($log['type']) && $log['type'] === 'response') { + $status = $log['status'] ?? 0; + if ($status >= 400) { + return true; + } + } + } + + return false; + } } diff --git a/app/Services/FlowTester/ApiLogCapturer.php b/app/Services/FlowTester/ApiLogCapturer.php new file mode 100644 index 00000000..c3002b77 --- /dev/null +++ b/app/Services/FlowTester/ApiLogCapturer.php @@ -0,0 +1,171 @@ +logPath = $this->resolveLogPath(); + } + + /** + * 캡처 시작 (현재 로그 파일 위치 기록) + */ + public function start(): void + { + if (file_exists($this->logPath)) { + $this->startOffset = filesize($this->logPath); + } else { + $this->startOffset = 0; + } + } + + /** + * 캡처 종료 및 로그 추출 + * + * @return array 파싱된 API 로그 배열 + */ + public function capture(): array + { + if (! file_exists($this->logPath)) { + return []; + } + + $currentSize = filesize($this->logPath); + + // 새로운 로그가 없으면 빈 배열 반환 + if ($currentSize <= $this->startOffset) { + return []; + } + + // 새로 추가된 로그 읽기 + $handle = fopen($this->logPath, 'r'); + if (! $handle) { + return []; + } + + fseek($handle, $this->startOffset); + $newLogs = fread($handle, $currentSize - $this->startOffset); + fclose($handle); + + // Request/Response 로그만 파싱 + return $this->parseLogs($newLogs); + } + + /** + * API 로그 파일 경로 결정 + */ + private function resolveLogPath(): string + { + // 1. 환경 변수로 지정된 경로 + $envPath = env('API_LOG_PATH'); + if ($envPath && file_exists($envPath)) { + return $envPath; + } + + // 2. 로컬 개발 환경 (상대 경로) + $localPath = base_path('../api/storage/logs/laravel.log'); + if (file_exists($localPath)) { + return $localPath; + } + + // 3. Docker 환경 (절대 경로) + $dockerPath = '/var/www/api/storage/logs/laravel.log'; + if (file_exists($dockerPath)) { + return $dockerPath; + } + + // 4. 기본값 (로컬 경로) + return $localPath; + } + + /** + * 로그 텍스트 파싱 + * + * Laravel 로그 형식: + * [2025-12-04 12:30:53] local.INFO: API Request {...} + * [2025-12-04 12:30:53] local.INFO: API Response {...} + */ + private function parseLogs(string $rawLogs): array + { + $logs = []; + $pattern = '/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.\w+: (API Request|API Response) (\{.+?\})(?=\s*\[\d{4}|\s*$)/s'; + + if (preg_match_all($pattern, $rawLogs, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $timestamp = $match[1]; + $type = $match[2] === 'API Request' ? 'request' : 'response'; + $jsonStr = $match[3]; + + // JSON 파싱 + $data = json_decode($jsonStr, true); + if ($data === null) { + continue; + } + + $log = [ + 'timestamp' => $timestamp, + 'type' => $type, + ]; + + if ($type === 'request') { + $log['method'] = $data['method'] ?? ''; + $log['uri'] = $data['uri'] ?? ''; + $log['input'] = $data['input'] ?? []; + $log['ip'] = $data['ip'] ?? ''; + } else { + $log['uri'] = $data['uri'] ?? ''; + $log['status'] = $data['status'] ?? 0; + + // content는 JSON 문자열이므로 파싱 + $content = $data['content'] ?? ''; + if (is_string($content)) { + $parsedContent = json_decode($content, true); + if ($parsedContent !== null) { + $log['success'] = $parsedContent['success'] ?? null; + $log['message'] = $parsedContent['message'] ?? ''; + + // 에러 정보가 있으면 포함 + if (isset($parsedContent['error'])) { + $log['error'] = $parsedContent['error']; + } + } + } + } + + $logs[] = $log; + } + } + + return $logs; + } + + /** + * 로그 파일 경로 반환 (디버깅용) + */ + public function getLogPath(): string + { + return $this->logPath; + } + + /** + * 로그 파일 존재 여부 확인 + */ + public function isAvailable(): bool + { + return file_exists($this->logPath) && is_readable($this->logPath); + } +} \ No newline at end of file diff --git a/app/Services/FlowTester/FlowExecutor.php b/app/Services/FlowTester/FlowExecutor.php index 4a23718e..09bd3c9b 100644 --- a/app/Services/FlowTester/FlowExecutor.php +++ b/app/Services/FlowTester/FlowExecutor.php @@ -26,6 +26,8 @@ class FlowExecutor private HttpClient $httpClient; + private ApiLogCapturer $logCapturer; + /** * 실행 로그 */ @@ -48,16 +50,23 @@ class FlowExecutor */ private array $stepSuccessMap = []; + /** + * 캡처된 API 로그 + */ + private array $apiLogs = []; + public function __construct( ?VariableBinder $binder = null, ?DependencyResolver $resolver = null, ?ResponseValidator $validator = null, - ?HttpClient $httpClient = null + ?HttpClient $httpClient = null, + ?ApiLogCapturer $logCapturer = null ) { $this->binder = $binder ?? new VariableBinder; $this->resolver = $resolver ?? new DependencyResolver; $this->validator = $validator ?? new ResponseValidator; $this->httpClient = $httpClient ?? new HttpClient; + $this->logCapturer = $logCapturer ?? new ApiLogCapturer; } /** @@ -72,6 +81,9 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra $startTime = microtime(true); $this->reset(); + // API 로그 캡처 시작 + $this->logCapturer->start(); + try { // 1. 플로우 정의 검증 $this->validateFlowDefinition($flowDefinition); @@ -242,6 +254,10 @@ private function executeStep(array $step): array if (empty($expect) || ! isset($expect['status'])) { $expect['status'] = [200, 201, 204]; } + + // expect 값에도 변수 바인딩 적용 (jsonPath 값의 {{변수}} 치환) + $expect = $this->bindExpectVariables($expect); + $validation = $this->validator->validate($response, $expect); // 6. 변수 추출 @@ -317,9 +333,10 @@ private function validateFlowDefinition(array $definition): void */ private function applyConfig(array $config): void { - // Base URL - Docker 환경 자동 변환 - if (isset($config['baseUrl'])) { - $baseUrl = $config['baseUrl']; + // Base URL 결정 - JSON에 있으면 사용, 없으면 .env에서 + $baseUrl = $this->resolveBaseUrl($config['baseUrl'] ?? null); + + if ($baseUrl) { // Docker 환경에서 외부 URL을 내부 URL로 변환 if ($this->isDockerEnvironment()) { @@ -395,6 +412,46 @@ private function getDefaultBearerToken(): ?string return env('FLOW_TESTER_API_TOKEN'); } + /** + * Base URL 결정 + * + * 우선순위: + * 1. JSON에 완전한 URL (도메인 포함) → 그대로 사용 + * 2. JSON에 경로만 있거나 없음 → .env의 FLOW_TESTER_API_BASE_URL + 경로 + * + * @param string|null $configBaseUrl JSON config의 baseUrl + * @return string|null 최종 Base URL + */ + private function resolveBaseUrl(?string $configBaseUrl): ?string + { + // 환경변수에서 기본 도메인 가져오기 + $envBaseUrl = env('FLOW_TESTER_API_BASE_URL'); + + // JSON에 baseUrl이 없는 경우 + if (empty($configBaseUrl)) { + return $envBaseUrl ?: null; + } + + // JSON에 완전한 URL이 있는지 확인 (http:// 또는 https://로 시작) + if (preg_match('#^https?://#', $configBaseUrl)) { + // 완전한 URL이면 그대로 사용 + return $configBaseUrl; + } + + // 상대 경로만 있는 경우 (.env 도메인 + 상대 경로) + if ($envBaseUrl) { + // .env URL 끝의 슬래시 제거 + $envBaseUrl = rtrim($envBaseUrl, '/'); + // 상대 경로 시작의 슬래시 보장 + $configBaseUrl = '/'.ltrim($configBaseUrl, '/'); + + return $envBaseUrl.$configBaseUrl; + } + + // .env도 없고 상대 경로만 있으면 그대로 반환 (실패할 수 있음) + return $configBaseUrl; + } + /** * Docker 환경인지 확인 */ @@ -449,6 +506,9 @@ private function buildResult( ): array { $duration = (int) ((microtime(true) - $startTime) * 1000); + // API 로그 캡처 + $this->apiLogs = $this->logCapturer->capture(); + return [ 'status' => $status, 'duration' => $duration, @@ -457,6 +517,7 @@ private function buildResult( 'failedStep' => $failedStep, 'errorMessage' => $errorMessage, 'executionLog' => $this->executionLog, + 'apiLogs' => $this->apiLogs, 'startedAt' => date('Y-m-d H:i:s', (int) $startTime), 'completedAt' => date('Y-m-d H:i:s'), ]; @@ -472,6 +533,7 @@ private function reset(): void $this->completedSteps = 0; $this->totalSteps = 0; $this->stepSuccessMap = []; + $this->apiLogs = []; $this->binder->reset(); } @@ -560,6 +622,27 @@ private function buildResultReason(bool $success, array $expect, int $actualStat return $reason; } + /** + * expect 값에 변수 바인딩 적용 + * + * jsonPath 값들의 {{변수}} 플레이스홀더를 치환합니다. + * 예: "$.data.client_code": "{{test_client_code}}" → "TEST_CLIENT_123" + */ + private function bindExpectVariables(array $expect): array + { + // jsonPath 값들에 변수 바인딩 적용 + if (isset($expect['jsonPath']) && is_array($expect['jsonPath'])) { + foreach ($expect['jsonPath'] as $path => $expected) { + // 문자열이고 {{}} 플레이스홀더가 있는 경우만 바인딩 + if (is_string($expected) && str_contains($expected, '{{')) { + $expect['jsonPath'][$path] = $this->binder->bind($expected); + } + } + } + + return $expect; + } + /** * assertions 배열 형식을 expect 객체 형식으로 변환 * diff --git a/app/Services/FlowTester/ResponseValidator.php b/app/Services/FlowTester/ResponseValidator.php index e96d60f3..c1f2a869 100644 --- a/app/Services/FlowTester/ResponseValidator.php +++ b/app/Services/FlowTester/ResponseValidator.php @@ -91,6 +91,12 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s { // 직접 값 비교 (연산자가 아닌 경우) if (! is_string($expected) || ! str_starts_with($expected, '@')) { + // 숫자 비교: 둘 다 숫자(또는 숫자 문자열)인 경우 타입 무관하게 비교 + // 예: "2" == 2, "123" == 123 + if ($this->areNumericEqual($actual, $expected)) { + return null; + } + if ($actual !== $expected) { return sprintf( 'Path %s: expected %s, got %s', @@ -266,6 +272,30 @@ private function formatList(array $items): string return '['.implode(', ', $items).']'; } + /** + * 숫자 값 비교 (타입 무관) + * + * 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다. + * 예: "2" == 2 → true, "123" == 123 → true + */ + private function areNumericEqual(mixed $actual, mixed $expected): bool + { + // 둘 다 숫자 또는 숫자 문자열인 경우에만 비교 + if (is_numeric($actual) && is_numeric($expected)) { + // 정수 비교가 가능한 경우 정수로 비교 + if (is_int($actual) || is_int($expected) || + (is_string($actual) && ctype_digit(ltrim($actual, '-'))) || + (is_string($expected) && ctype_digit(ltrim($expected, '-')))) { + return (int) $actual === (int) $expected; + } + + // 그 외의 경우 float로 비교 + return (float) $actual === (float) $expected; + } + + return false; + } + /** * 응답에서 값 추출 (extract 처리) * diff --git a/resources/views/dev-tools/flow-tester/index.blade.php b/resources/views/dev-tools/flow-tester/index.blade.php index 4eb24ffd..41d83031 100644 --- a/resources/views/dev-tools/flow-tester/index.blade.php +++ b/resources/views/dev-tools/flow-tester/index.blade.php @@ -78,11 +78,13 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
| # | 이름 | 카테고리 | 스텝 | 최근 실행 | 상태 | +생성일 | 액션 | |||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + {{ $flows->firstItem() + $loop->index }} + |
|
- + | @if($flow->category) {{ $flow->category }} @else - @endif | -+ | {{ $flow->step_count }}개 | -+ | @if($latestRun) {{ $latestRun->created_at->diffForHumans() }} @else - @endif | -+ | @if($latestRun) {{ $latestRun->status_label }} @@ -133,8 +138,11 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc 대기 @endif | -
-
+
+ {{ $flow->created_at->format('y.m.d') }}
+ |
+
+ | |