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 + + @@ -94,6 +96,9 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc @endphp {{-- 메인 row --}} + - - - - - +
# 이름 카테고리 스텝 최근 실행 상태생성일 액션
+ {{ $flows->firstItem() + $loop->index }} +
@@ -107,24 +112,24 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
+ @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') }} + +
+

플로우 스텝 ({{ count($steps) }}개)

@@ -555,6 +563,8 @@ function showResultModal(data) {
` : ''} + ${getApiLogSummary(result.apiLogs)} +
-
품목관리(Item Master) API 통합 테스트 플로우를 만들어줘.
+                            
인증 API 테스트 플로우를 만들어줘.
 
 ## 테스트 목적
-품목관리의 페이지, 섹션, 필드 CRUD 및 연결/해제 기능 검증
+로그인, 프로필 조회, 토큰 갱신, 로그아웃 전체 인증 플로우 검증
 
 ## API 서버
-- Base URL: https://sam.kr/api/v1
-- 인증: Bearer Token
+- Base URL: https://api.sam.kr/api/v1
+- 인증: user_id/user_pwd → access_token/refresh_token
 
 ## 테스트 시나리오
-1. 페이지 생성 → 삭제 → 다시 생성
-2. 섹션 독립 생성 → 삭제 → 다시 생성
-3. 필드 독립 생성 → 삭제 → 다시 생성
-4. 페이지에 섹션 연결 → 연결 해제 → 다시 연결
-5. 섹션에 필드 연결 → 연결 해제
-6. 정리: 생성된 리소스 삭제
+1. 로그인 → access_token, refresh_token 추출
+2. 프로필 조회 → Authorization 헤더에 access_token 사용
+3. 토큰 갱신 → refresh_token으로 새 access_token 발급
+4. 로그아웃 → 새 access_token으로 로그아웃
 
 ## API 엔드포인트
-- POST /item-master/pages - 페이지 생성
-- DELETE /item-master/pages/{id} - 페이지 삭제
-- POST /item-master/sections - 섹션 독립 생성
-- POST /item-master/pages/{id}/link-section - 섹션 연결
-- POST /item-master/sections/{id}/link-field - 필드 연결
+- POST /login - { user_id, user_pwd } → { access_token, refresh_token }
+- GET /users/me - Authorization: Bearer {token} → { success: true }
+- POST /refresh - { refresh_token } → { access_token }
+- POST /logout - Authorization: Bearer {token}
 
-## JSON 형식 (meta 포함 필수!)
-- meta.name: "품목관리 API 통합 테스트"
-- meta.tags: ["item-master", "integration", "crud"]
-- meta.description: "페이지/섹션/필드 CRUD 및 연결 기능 검증"
+## JSON 형식
+{
+  "name": "인증 플로우 테스트",
+  "description": "로그인, 프로필 조회, 토큰 갱신, 로그아웃 플로우를 테스트합니다.",
+  "version": "1.0",
+  "config": {
+    "baseUrl": "https://api.sam.kr/api/v1",
+    "apiKey": "YOUR_API_KEY",
+    "timeout": 30000,
+    "stopOnFailure": true
+  },
+  "variables": {
+    "user_id": "@{{$env.FLOW_TESTER_USER_ID}}",
+    "user_pwd": "@{{$env.FLOW_TESTER_USER_PWD}}"
+  }
+}
 
-각 API 응답에서 생성된 ID를 추출해서 다음 단계에서 사용해줘.
-테스트 데이터는 "TEST_" 접두사를 붙여줘.
+extract로 token을 추출하고 다음 스텝 headers에서 사용해줘.
diff --git a/resources/views/dev-tools/flow-tester/run-detail.blade.php b/resources/views/dev-tools/flow-tester/run-detail.blade.php index ee7b1a77..847ef7c3 100644 --- a/resources/views/dev-tools/flow-tester/run-detail.blade.php +++ b/resources/views/dev-tools/flow-tester/run-detail.blade.php @@ -84,80 +84,228 @@ class="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition"> @endif
- +
-
-

실행 로그

+
+ +
+ + +
- @if($run->execution_log) -
- @foreach($run->execution_log as $index => $log) -
-
-
- @if($log['success'] ?? false) - - + +
+ @if($run->execution_log) +
+ @foreach($run->execution_log as $index => $log) +
+ {{-- 헤더 (항상 표시, 클릭으로 토글) --}} +
+
+ {{-- 펼침/접힘 아이콘 --}} + + - @else - - - - @endif - {{ $log['step_id'] ?? 'Step '.($index + 1) }} - {{ $log['name'] ?? '' }} + {{-- 성공/실패 아이콘 --}} + @if($log['success'] ?? false) + + + + @else + + + + @endif + {{ $log['step_id'] ?? 'Step '.($index + 1) }} + {{ $log['name'] ?? '' }} + {{-- 간단한 요약 정보 --}} + @if(isset($log['request'])) + + {{ $log['request']['method'] ?? '' }} {{ Str::limit($log['request']['endpoint'] ?? '', 30) }} + + @endif +
+
+ @if(isset($log['response']['status'])) + + {{ $log['response']['status'] }} + + @endif + {{ $log['duration'] ?? '' }}ms +
+
+ + {{-- 상세 내용 (펼쳤을 때만 표시) --}} +
+
+ {{-- Request 상세 --}} + @if(isset($log['request'])) +
+
Request
+
+ {{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }} + @if(!empty($log['request']['body'])) +
+ 요청 데이터 +
{{ json_encode($log['request']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+
+ @endif +
+
+ @endif + + {{-- Response 상세 --}} + @if(isset($log['response'])) +
+
Response
+
+
+ + {{ $log['response']['status'] ?? '-' }} + + @if(isset($log['expect']['status'])) + + 예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }} + + @endif +
+ @if(!empty($log['response']['body'])) +
+ 응답 데이터 +
{{ json_encode($log['response']['body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+
+ @endif +
+
+ @endif + + {{-- 성공/실패 이유 --}} + @if(isset($log['reason'])) +
+ {{ ($log['success'] ?? false) ? '성공' : '실패' }} 이유: + {{ $log['reason'] }} +
+ @endif + + {{-- 스텝 설명 --}} + @if(isset($log['description']) && $log['description']) +
+ {{ $log['description'] }} +
+ @endif + + {{-- 에러 상세 --}} + @if(isset($log['error'])) +
+
에러
+
{{ $log['error'] }}
+
+ @endif +
- {{ $log['duration'] ?? '' }}ms
+ @endforeach +
+ @else +
+

실행 로그가 없습니다.

+
+ @endif +
- @if(isset($log['request'])) -
- Request: - {{ $log['request']['method'] ?? '' }} {{ $log['request']['endpoint'] ?? '' }} + +
+ @if($run->api_logs && count($run->api_logs) > 0) +
+ @foreach($run->api_logs as $index => $apiLog) + @php + $isRequest = ($apiLog['type'] ?? '') === 'request'; + $isError = !$isRequest && (($apiLog['status'] ?? 0) >= 400 || ($apiLog['success'] ?? true) === false); + @endphp +
+
+
+ @if($isRequest) + REQ + @else + RES + @endif + + @if($isRequest) + + {{ $apiLog['method'] ?? '' }} {{ $apiLog['uri'] ?? '' }} + + @else + + {{ $apiLog['uri'] ?? '' }} + + + {{ $apiLog['status'] ?? '-' }} + + @endif +
+ {{ $apiLog['timestamp'] ?? '' }}
- @endif - @if(isset($log['response'])) -
- Response: - - {{ $log['response']['status'] ?? '-' }} - - @if(isset($log['expect']['status'])) - - (예상: {{ is_array($log['expect']['status']) ? implode(', ', $log['expect']['status']) : $log['expect']['status'] }}) - + @if($isRequest && !empty($apiLog['input'])) +
+
+ 요청 데이터 +
{{ json_encode($apiLog['input'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}
+
+
+ @endif + + @if(!$isRequest) + @if(isset($apiLog['message']) && $apiLog['message']) +
+ {{ $apiLog['message'] }} +
@endif -
- @endif - {{-- 성공/실패 이유 설명 --}} - @if(isset($log['reason'])) -
- {{ $log['reason'] }} -
- @endif - - {{-- 스텝 설명 --}} - @if(isset($log['description']) && $log['description']) -
- 💡 {{ $log['description'] }} -
- @endif - - @if(isset($log['error'])) -
- {{ $log['error'] }} -
- @endif -
- @endforeach -
- @else -
-

실행 로그가 없습니다.

-
- @endif + @if(isset($apiLog['error'])) +
+
+ 오류 상세 +
{{ is_array($apiLog['error']) ? json_encode($apiLog['error'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : $apiLog['error'] }}
+
+
+ @endif + @endif +
+ @endforeach +
+ @else +
+ + + +

API 로그가 없습니다.

+

API 서버의 로그 파일에서 캡처됩니다.

+
+ @endif +