- FlowTesterController: 테스트 실행 로직 개선 - 에러 핸들링 강화 - 응답 형식 표준화 - FlowExecutor: API 호출 실행기 개선 - 다단계 플로우 지원 강화 - 변수 바인딩 및 검증 개선 - index.blade.php: UI 개선 - 테스트 결과 표시 개선 - 사용성 향상 - routes/web.php: 라우트 정리 - composer.lock: 의존성 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
863 lines
29 KiB
PHP
863 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Services\FlowTester;
|
|
|
|
use Exception;
|
|
|
|
/**
|
|
* 플로우 실행 엔진
|
|
*
|
|
* 플로우 정의를 받아 단계별로 실행하고 결과를 반환합니다.
|
|
*
|
|
* 실행 흐름:
|
|
* 1. 플로우 정의 로드 및 검증
|
|
* 2. 의존성 기반 실행 순서 결정 (TopSort)
|
|
* 3. 변수 바인딩 초기화
|
|
* 4. 각 단계 순차 실행
|
|
* 5. 결과 수집 및 반환
|
|
*/
|
|
class FlowExecutor
|
|
{
|
|
private VariableBinder $binder;
|
|
|
|
private DependencyResolver $resolver;
|
|
|
|
private ResponseValidator $validator;
|
|
|
|
private HttpClient $httpClient;
|
|
|
|
private ApiLogCapturer $logCapturer;
|
|
|
|
/**
|
|
* 실행 로그
|
|
*/
|
|
private array $executionLog = [];
|
|
|
|
/**
|
|
* 실행 상태
|
|
*/
|
|
private string $status = 'PENDING';
|
|
|
|
/**
|
|
* 현재 진행 상황
|
|
*/
|
|
private int $completedSteps = 0;
|
|
|
|
private int $totalSteps = 0;
|
|
|
|
/**
|
|
* 스텝별 성공/실패 추적
|
|
*/
|
|
private array $stepSuccessMap = [];
|
|
|
|
/**
|
|
* 캡처된 API 로그
|
|
*/
|
|
private array $apiLogs = [];
|
|
|
|
public function __construct(
|
|
?VariableBinder $binder = null,
|
|
?DependencyResolver $resolver = null,
|
|
?ResponseValidator $validator = 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;
|
|
}
|
|
|
|
/**
|
|
* 플로우 실행
|
|
*
|
|
* @param array $flowDefinition 플로우 정의 JSON
|
|
* @param array $inputVariables 입력 변수 (실행 시 주입)
|
|
* @return array 실행 결과
|
|
*/
|
|
public function execute(array $flowDefinition, array $inputVariables = []): array
|
|
{
|
|
$startTime = microtime(true);
|
|
$this->reset();
|
|
|
|
// API 로그 캡처 시작
|
|
$this->logCapturer->start();
|
|
|
|
try {
|
|
// 1. 플로우 정의 검증
|
|
$this->validateFlowDefinition($flowDefinition);
|
|
|
|
// 2. 설정 적용
|
|
$this->applyConfig($flowDefinition['config'] ?? []);
|
|
|
|
// 3. 전역 변수 초기화
|
|
$variables = array_merge(
|
|
$flowDefinition['variables'] ?? [],
|
|
$inputVariables
|
|
);
|
|
$this->binder->setVariables($variables);
|
|
|
|
// 4. 스텝 정보 추출
|
|
$steps = $flowDefinition['steps'] ?? [];
|
|
$this->totalSteps = count($steps);
|
|
|
|
if ($this->totalSteps === 0) {
|
|
return $this->buildResult($startTime, 'SUCCESS', 'No steps to execute');
|
|
}
|
|
|
|
// 5. 의존성 정렬
|
|
$orderedStepIds = $this->resolver->resolve($steps);
|
|
$stepMap = $this->buildStepMap($steps);
|
|
|
|
// 6. 실행 상태 변경
|
|
$this->status = 'RUNNING';
|
|
|
|
// 7. 각 단계 실행
|
|
$stopOnFailure = $flowDefinition['config']['stopOnFailure'] ?? true;
|
|
|
|
foreach ($orderedStepIds as $stepId) {
|
|
$step = $stepMap[$stepId];
|
|
|
|
// 의존성 스텝 성공 여부 확인
|
|
$dependencyCheck = $this->checkDependencies($step);
|
|
if (! $dependencyCheck['canRun']) {
|
|
// 의존성 실패로 스킵
|
|
$skipResult = $this->buildStepResult($stepId, $step['name'] ?? $stepId, microtime(true), false, [
|
|
'skipped' => true,
|
|
'skipReason' => $dependencyCheck['reason'],
|
|
'failedDependencies' => $dependencyCheck['failedDeps'],
|
|
]);
|
|
$this->executionLog[] = $skipResult;
|
|
$this->stepSuccessMap[$stepId] = false;
|
|
|
|
continue;
|
|
}
|
|
|
|
$stepResult = $this->executeStep($step);
|
|
|
|
$this->executionLog[] = $stepResult;
|
|
$this->stepSuccessMap[$stepId] = $stepResult['success'];
|
|
|
|
if ($stepResult['success']) {
|
|
$this->completedSteps++;
|
|
} else {
|
|
if (! ($step['continueOnFailure'] ?? false) && $stopOnFailure) {
|
|
$this->status = 'FAILED';
|
|
|
|
return $this->buildResult(
|
|
$startTime,
|
|
'FAILED',
|
|
"Step '{$stepId}' failed: ".($stepResult['error'] ?? 'Unknown error'),
|
|
$stepId
|
|
);
|
|
}
|
|
// continueOnFailure가 true면 계속 진행
|
|
}
|
|
}
|
|
|
|
// 8. 최종 상태 결정
|
|
$this->status = $this->completedSteps === $this->totalSteps ? 'SUCCESS' : 'PARTIAL';
|
|
|
|
return $this->buildResult($startTime, $this->status);
|
|
} catch (Exception $e) {
|
|
$this->status = 'FAILED';
|
|
|
|
return $this->buildResult($startTime, 'FAILED', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 의존성 스텝 성공 여부 확인
|
|
*
|
|
* @param array $step 실행할 스텝
|
|
* @return array ['canRun' => bool, 'reason' => string|null, 'failedDeps' => array]
|
|
*/
|
|
private function checkDependencies(array $step): array
|
|
{
|
|
$dependencies = $step['dependsOn'] ?? [];
|
|
|
|
if (empty($dependencies)) {
|
|
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
|
|
}
|
|
|
|
$failedDeps = [];
|
|
|
|
foreach ($dependencies as $depId) {
|
|
// 의존성 스텝이 실행되지 않았거나 실패한 경우
|
|
if (! isset($this->stepSuccessMap[$depId])) {
|
|
$failedDeps[] = $depId.' (not executed)';
|
|
} elseif (! $this->stepSuccessMap[$depId]) {
|
|
$failedDeps[] = $depId.' (failed)';
|
|
}
|
|
}
|
|
|
|
if (! empty($failedDeps)) {
|
|
return [
|
|
'canRun' => false,
|
|
'reason' => 'Dependency failed: '.implode(', ', $failedDeps),
|
|
'failedDeps' => $failedDeps,
|
|
];
|
|
}
|
|
|
|
return ['canRun' => true, 'reason' => null, 'failedDeps' => []];
|
|
}
|
|
|
|
/**
|
|
* 단일 스텝 실행
|
|
*/
|
|
private function executeStep(array $step): array
|
|
{
|
|
$stepId = $step['id'];
|
|
$stepName = $step['name'] ?? $stepId;
|
|
$startTime = microtime(true);
|
|
|
|
try {
|
|
// 0. useSessionAuth 옵션 처리 - 세션 인증 정보로 대체
|
|
if (! empty($step['useSessionAuth'])) {
|
|
return $this->executeSessionAuthStep($step, $stepId, $stepName, $startTime);
|
|
}
|
|
|
|
// 1. 딜레이 적용
|
|
$delay = $step['delay'] ?? 0;
|
|
if ($delay > 0) {
|
|
usleep($delay * 1000); // ms → μs
|
|
}
|
|
|
|
// 2. 변수 바인딩
|
|
$endpoint = $this->binder->bind($step['endpoint']);
|
|
$headers = $this->binder->bind($step['headers'] ?? []);
|
|
$body = $this->binder->bind($step['body'] ?? []);
|
|
$query = $this->binder->bind($step['query'] ?? []);
|
|
|
|
// 3. HTTP 요청 실행
|
|
$method = strtoupper($step['method']);
|
|
$response = $this->httpClient->request($method, $endpoint, [
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
'query' => $query,
|
|
]);
|
|
|
|
// 4. HTTP 에러 체크
|
|
if ($response['error']) {
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
|
|
'error' => $response['error'],
|
|
'request' => [
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
'query' => $query,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// 5. 응답 검증 (expect 또는 assertions 둘 다 지원)
|
|
$expect = $step['expect'] ?? [];
|
|
if (empty($expect) && isset($step['assertions'])) {
|
|
$expect = $this->convertAssertionsToExpect($step['assertions']);
|
|
}
|
|
|
|
// expect가 비어있으면 기본적으로 2xx 상태 코드 기대
|
|
if (empty($expect) || ! isset($expect['status'])) {
|
|
$expect['status'] = [200, 201, 204];
|
|
}
|
|
|
|
// expect 값에도 변수 바인딩 적용 (jsonPath 값의 {{변수}} 치환)
|
|
$expect = $this->bindExpectVariables($expect);
|
|
|
|
$validation = $this->validator->validate($response, $expect);
|
|
|
|
// 6. 변수 추출
|
|
$extracted = [];
|
|
if (isset($step['extract'])) {
|
|
$extracted = $this->validator->extractValues($response['body'], $step['extract']);
|
|
$this->binder->setStepResult($stepId, $extracted, $response['body']);
|
|
}
|
|
|
|
// 7. 결과 구성
|
|
$success = $validation['success'];
|
|
// 성공/실패 이유 설명 생성
|
|
$reason = $this->buildResultReason($success, $expect, $response['status'], $step);
|
|
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, $success, [
|
|
'description' => $step['description'] ?? null,
|
|
'reason' => $reason,
|
|
'expect' => $expect,
|
|
'request' => [
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
'query' => $query,
|
|
],
|
|
'response' => [
|
|
'status' => $response['status'],
|
|
'body' => $response['body'],
|
|
'duration' => $response['duration'],
|
|
],
|
|
'extracted' => $extracted,
|
|
'validation' => $validation,
|
|
'error' => $success ? null : implode('; ', $validation['errors']),
|
|
]);
|
|
} catch (Exception $e) {
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세션 인증 스텝 실행
|
|
*
|
|
* useSessionAuth: true 옵션이 있는 login 스텝에서 사용
|
|
* - 세션 토큰이 있으면 → 세션 토큰 사용 (API 호출 스킵)
|
|
* - 세션 토큰이 없으면 → .env 크레덴셜로 실제 API 로그인
|
|
*
|
|
* @param array $step 스텝 정의
|
|
* @param string $stepId 스텝 ID
|
|
* @param string $stepName 스텝 이름
|
|
* @param float $startTime 시작 시간
|
|
* @return array 스텝 결과
|
|
*/
|
|
private function executeSessionAuthStep(array $step, string $stepId, string $stepName, float $startTime): array
|
|
{
|
|
// 세션 인증 정보 조회
|
|
$sessionAuth = $this->binder->getSessionAuth();
|
|
|
|
// 세션 토큰이 있으면 사용 (API 호출 스킵)
|
|
if (! empty($sessionAuth['token'])) {
|
|
$extracted = [
|
|
'token' => $sessionAuth['token'],
|
|
'access_token' => $sessionAuth['token'],
|
|
];
|
|
|
|
if ($sessionAuth['user']) {
|
|
$extracted['user_id'] = $sessionAuth['user']['id'];
|
|
$extracted['user_name'] = $sessionAuth['user']['name'];
|
|
$extracted['user_email'] = $sessionAuth['user']['email'];
|
|
}
|
|
if ($sessionAuth['tenant_id']) {
|
|
$extracted['tenant_id'] = $sessionAuth['tenant_id'];
|
|
}
|
|
|
|
$this->binder->setStepResult($stepId, $extracted, [
|
|
'message' => '세션 인증 사용',
|
|
'user' => $sessionAuth['user'],
|
|
'tenant_id' => $sessionAuth['tenant_id'],
|
|
]);
|
|
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, true, [
|
|
'description' => $step['description'] ?? '세션 인증 정보 사용',
|
|
'reason' => '✓ 세션 인증 사용 (API 호출 생략)',
|
|
'useSessionAuth' => true,
|
|
'sessionUser' => $sessionAuth['user'],
|
|
'extracted' => $extracted,
|
|
'response' => [
|
|
'status' => 200,
|
|
'body' => [
|
|
'message' => '세션 인증 정보를 사용합니다.',
|
|
'access_token' => substr($sessionAuth['token'], 0, 20).'...',
|
|
'user' => $sessionAuth['user'],
|
|
'tenant_id' => $sessionAuth['tenant_id'],
|
|
],
|
|
'duration' => 0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// 세션 토큰이 없으면 .env로 폴백하여 실제 API 로그인
|
|
// useSessionAuth 옵션을 제거하고 일반 스텝처럼 실행
|
|
$fallbackStep = $step;
|
|
unset($fallbackStep['useSessionAuth']);
|
|
$fallbackStep['description'] = '.env 크레덴셜로 API 로그인 (세션 토큰 없음)';
|
|
|
|
return $this->executeLoginStep($fallbackStep, $stepId, $stepName, $startTime);
|
|
}
|
|
|
|
/**
|
|
* 일반 로그인 스텝 실행 (실제 API 호출)
|
|
*
|
|
* @param array $step 스텝 정의
|
|
* @param string $stepId 스텝 ID
|
|
* @param string $stepName 스텝 이름
|
|
* @param float $startTime 시작 시간
|
|
* @return array 스텝 결과
|
|
*/
|
|
private function executeLoginStep(array $step, string $stepId, string $stepName, float $startTime): array
|
|
{
|
|
try {
|
|
// 딜레이 적용
|
|
$delay = $step['delay'] ?? 0;
|
|
if ($delay > 0) {
|
|
usleep($delay * 1000);
|
|
}
|
|
|
|
// 변수 바인딩
|
|
$endpoint = $this->binder->bind($step['endpoint']);
|
|
$headers = $this->binder->bind($step['headers'] ?? []);
|
|
$body = $this->binder->bind($step['body'] ?? []);
|
|
$query = $this->binder->bind($step['query'] ?? []);
|
|
|
|
// HTTP 요청 실행
|
|
$method = strtoupper($step['method']);
|
|
$response = $this->httpClient->request($method, $endpoint, [
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
'query' => $query,
|
|
]);
|
|
|
|
// HTTP 에러 체크
|
|
if ($response['error']) {
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
|
|
'error' => $response['error'],
|
|
'reason' => '✗ .env 폴백 로그인 실패: '.$response['error'],
|
|
'request' => [
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// 응답 검증
|
|
$expect = $step['expect'] ?? ['status' => [200, 201]];
|
|
$validation = $this->validator->validate($response, $expect);
|
|
|
|
// 변수 추출
|
|
$extracted = [];
|
|
if (isset($step['extract'])) {
|
|
$extracted = $this->validator->extractValues($response['body'], $step['extract']);
|
|
$this->binder->setStepResult($stepId, $extracted, $response['body']);
|
|
}
|
|
|
|
$success = $validation['success'];
|
|
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, $success, [
|
|
'description' => $step['description'] ?? '.env 크레덴셜로 API 로그인',
|
|
'reason' => $success
|
|
? '✓ .env 폴백 로그인 성공'
|
|
: '✗ .env 폴백 로그인 실패: '.implode('; ', $validation['errors']),
|
|
'fallbackLogin' => true,
|
|
'request' => [
|
|
'method' => $method,
|
|
'endpoint' => $endpoint,
|
|
'headers' => $headers,
|
|
'body' => $body,
|
|
],
|
|
'response' => [
|
|
'status' => $response['status'],
|
|
'body' => $response['body'],
|
|
'duration' => $response['duration'],
|
|
],
|
|
'extracted' => $extracted,
|
|
'validation' => $validation,
|
|
'error' => $success ? null : implode('; ', $validation['errors']),
|
|
]);
|
|
} catch (Exception $e) {
|
|
return $this->buildStepResult($stepId, $stepName, $startTime, false, [
|
|
'error' => $e->getMessage(),
|
|
'reason' => '✗ .env 폴백 로그인 예외: '.$e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플로우 정의 검증
|
|
*/
|
|
private function validateFlowDefinition(array $definition): void
|
|
{
|
|
// steps 필드 필수
|
|
if (! isset($definition['steps']) || ! is_array($definition['steps'])) {
|
|
throw new Exception('Flow definition must have a "steps" array');
|
|
}
|
|
|
|
// 각 스텝 검증
|
|
foreach ($definition['steps'] as $index => $step) {
|
|
if (! isset($step['id'])) {
|
|
throw new Exception("Step at index {$index} must have an 'id' field");
|
|
}
|
|
if (! isset($step['method'])) {
|
|
throw new Exception("Step '{$step['id']}' must have a 'method' field");
|
|
}
|
|
if (! isset($step['endpoint'])) {
|
|
throw new Exception("Step '{$step['id']}' must have an 'endpoint' field");
|
|
}
|
|
}
|
|
|
|
// 의존성 검증
|
|
$validation = $this->resolver->validate($definition['steps']);
|
|
if (! $validation['valid']) {
|
|
throw new Exception('Dependency validation failed: '.implode('; ', $validation['errors']));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설정 적용
|
|
*/
|
|
private function applyConfig(array $config): void
|
|
{
|
|
// Base URL 결정 - JSON에 있으면 사용, 없으면 .env에서
|
|
$baseUrl = $this->resolveBaseUrl($config['baseUrl'] ?? null);
|
|
|
|
if ($baseUrl) {
|
|
|
|
// Docker 환경에서 외부 URL을 내부 URL로 변환
|
|
if ($this->isDockerEnvironment()) {
|
|
$parsedUrl = parse_url($baseUrl);
|
|
$host = $parsedUrl['host'] ?? '';
|
|
|
|
// *.sam.kr 도메인을 nginx 컨테이너로 라우팅
|
|
if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') {
|
|
$this->httpClient->setHostHeader($host);
|
|
$this->httpClient->withoutVerifying();
|
|
|
|
// URL을 https://nginx/... 로 변환
|
|
$internalUrl = preg_replace(
|
|
'#https?://[^/]+#',
|
|
'https://nginx',
|
|
$baseUrl
|
|
);
|
|
$baseUrl = $internalUrl;
|
|
}
|
|
}
|
|
|
|
$this->httpClient->setBaseUrl($baseUrl);
|
|
}
|
|
|
|
// Timeout
|
|
if (isset($config['timeout'])) {
|
|
$timeout = (int) ($config['timeout'] / 1000); // ms → s
|
|
$this->httpClient->setTimeout(max(1, $timeout));
|
|
}
|
|
|
|
// 기본 헤더
|
|
if (isset($config['headers'])) {
|
|
$this->httpClient->setDefaultHeaders($config['headers']);
|
|
}
|
|
|
|
// 인증 - JSON에 없으면 .env에서 자동 로드
|
|
$apiKey = $config['apiKey'] ?? null;
|
|
if (empty($apiKey)) {
|
|
$apiKey = env('FLOW_TESTER_API_KEY');
|
|
} else {
|
|
// 플레이스홀더 치환 ({{$auth.apiKey}} 등)
|
|
$apiKey = $this->binder->bind($apiKey);
|
|
}
|
|
if (! empty($apiKey)) {
|
|
$this->httpClient->setApiKey($apiKey);
|
|
}
|
|
|
|
$bearerToken = $config['bearerToken'] ?? null;
|
|
if (empty($bearerToken)) {
|
|
// .env 또는 로그인 사용자의 토큰 사용
|
|
$bearerToken = $this->getDefaultBearerToken();
|
|
} else {
|
|
// 플레이스홀더 치환 ({{$auth.token}} 등)
|
|
$bearerToken = $this->binder->bind($bearerToken);
|
|
}
|
|
if (! empty($bearerToken)) {
|
|
$this->httpClient->setBearerToken($bearerToken);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 기본 Bearer 토큰 조회
|
|
*
|
|
* 세션 토큰만 사용 (API 서버 로그인으로 발급받은 토큰)
|
|
* .env 폴백은 MNG 토큰이 API 서버에서 인식되지 않으므로 제거됨
|
|
*/
|
|
private function getDefaultBearerToken(): ?string
|
|
{
|
|
// 세션에 저장된 토큰 (API 서버 로그인으로 발급받은 토큰)
|
|
return session('api_explorer_token') ?: null;
|
|
}
|
|
|
|
/**
|
|
* 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 환경인지 확인
|
|
*/
|
|
private function isDockerEnvironment(): bool
|
|
{
|
|
// Docker 컨테이너 내부에서 실행 중인지 확인
|
|
return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true');
|
|
}
|
|
|
|
/**
|
|
* 스텝 맵 구성 (id → step)
|
|
*/
|
|
private function buildStepMap(array $steps): array
|
|
{
|
|
$map = [];
|
|
foreach ($steps as $step) {
|
|
$map[$step['id']] = $step;
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* 스텝 결과 구성
|
|
*/
|
|
private function buildStepResult(
|
|
string $stepId,
|
|
string $stepName,
|
|
float $startTime,
|
|
bool $success,
|
|
array $details = []
|
|
): array {
|
|
$duration = (int) ((microtime(true) - $startTime) * 1000);
|
|
|
|
return array_merge([
|
|
'stepId' => $stepId,
|
|
'stepName' => $stepName,
|
|
'success' => $success,
|
|
'duration' => $duration,
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
], $details);
|
|
}
|
|
|
|
/**
|
|
* 최종 결과 구성
|
|
*/
|
|
private function buildResult(
|
|
float $startTime,
|
|
string $status,
|
|
?string $errorMessage = null,
|
|
?string $failedStep = null
|
|
): array {
|
|
$duration = (int) ((microtime(true) - $startTime) * 1000);
|
|
|
|
// API 로그 캡처
|
|
$this->apiLogs = $this->logCapturer->capture();
|
|
|
|
return [
|
|
'status' => $status,
|
|
'duration' => $duration,
|
|
'totalSteps' => $this->totalSteps,
|
|
'completedSteps' => $this->completedSteps,
|
|
'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'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 상태 초기화
|
|
*/
|
|
private function reset(): void
|
|
{
|
|
$this->executionLog = [];
|
|
$this->status = 'PENDING';
|
|
$this->completedSteps = 0;
|
|
$this->totalSteps = 0;
|
|
$this->stepSuccessMap = [];
|
|
$this->apiLogs = [];
|
|
$this->binder->reset();
|
|
}
|
|
|
|
/**
|
|
* 현재 진행 상황 조회
|
|
*/
|
|
public function getProgress(): array
|
|
{
|
|
return [
|
|
'status' => $this->status,
|
|
'completedSteps' => $this->completedSteps,
|
|
'totalSteps' => $this->totalSteps,
|
|
'percent' => $this->totalSteps > 0
|
|
? round(($this->completedSteps / $this->totalSteps) * 100, 1)
|
|
: 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 실행 로그 조회
|
|
*/
|
|
public function getExecutionLog(): array
|
|
{
|
|
return $this->executionLog;
|
|
}
|
|
|
|
/**
|
|
* 현재 상태 조회
|
|
*/
|
|
public function getStatus(): string
|
|
{
|
|
return $this->status;
|
|
}
|
|
|
|
/**
|
|
* 성공/실패 이유 설명 생성
|
|
*
|
|
* @param bool $success 성공 여부
|
|
* @param array $expect 예상 조건
|
|
* @param int $actualStatus 실제 HTTP 상태 코드
|
|
* @param array $step 스텝 정의
|
|
* @return string 이유 설명
|
|
*/
|
|
private function buildResultReason(bool $success, array $expect, int $actualStatus, array $step): string
|
|
{
|
|
$expectedStatuses = $expect['status'] ?? [200, 201, 204];
|
|
if (! is_array($expectedStatuses)) {
|
|
$expectedStatuses = [$expectedStatuses];
|
|
}
|
|
|
|
// 예상 상태 코드 문자열 생성
|
|
$expectedStr = implode(', ', $expectedStatuses);
|
|
|
|
// 400, 404 등 에러 코드가 예상값인 경우 (부정 테스트)
|
|
$isNegativeTest = ! empty(array_filter($expectedStatuses, fn ($s) => $s >= 400));
|
|
|
|
if ($success) {
|
|
if ($isNegativeTest) {
|
|
// 부정 테스트 성공 설명
|
|
$reason = "✓ 예상대로 {$actualStatus} 반환";
|
|
if ($actualStatus === 400) {
|
|
$reason .= ' (유효성 검증 실패 확인)';
|
|
} elseif ($actualStatus === 404) {
|
|
$reason .= ' (리소스 미존재 확인)';
|
|
} elseif ($actualStatus === 409) {
|
|
$reason .= ' (충돌 상태 확인)';
|
|
} elseif ($actualStatus === 403) {
|
|
$reason .= ' (권한 없음 확인)';
|
|
}
|
|
|
|
// 스텝 설명이 있으면 추가
|
|
if (! empty($step['description'])) {
|
|
$reason .= ' - '.$step['description'];
|
|
}
|
|
|
|
return $reason;
|
|
}
|
|
|
|
// 일반 성공
|
|
return "✓ 정상 응답 ({$actualStatus})";
|
|
}
|
|
|
|
// 실패 설명
|
|
$reason = "✗ 예상: [{$expectedStr}], 실제: {$actualStatus}";
|
|
|
|
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 객체 형식으로 변환
|
|
*
|
|
* Postman 스타일:
|
|
* [
|
|
* {"type": "status", "expected": 200},
|
|
* {"type": "jsonPath", "path": "data.id", "operator": "exists"},
|
|
* {"type": "jsonPath", "path": "data.name", "expected": "테스트"}
|
|
* ]
|
|
*
|
|
* → 내부 형식:
|
|
* {
|
|
* "status": 200,
|
|
* "jsonPath": {
|
|
* "data.id": "@exists",
|
|
* "data.name": "테스트"
|
|
* }
|
|
* }
|
|
*/
|
|
private function convertAssertionsToExpect(array $assertions): array
|
|
{
|
|
$expect = [];
|
|
|
|
foreach ($assertions as $assertion) {
|
|
$type = $assertion['type'] ?? '';
|
|
|
|
switch ($type) {
|
|
case 'status':
|
|
$expect['status'] = $assertion['expected'] ?? 200;
|
|
break;
|
|
|
|
case 'jsonPath':
|
|
$path = $assertion['path'] ?? '';
|
|
if (empty($path)) {
|
|
continue 2;
|
|
}
|
|
|
|
if (! isset($expect['jsonPath'])) {
|
|
$expect['jsonPath'] = [];
|
|
}
|
|
|
|
// operator가 있으면 @operator 형식으로 변환
|
|
if (isset($assertion['operator'])) {
|
|
$expect['jsonPath'][$path] = '@'.$assertion['operator'];
|
|
} elseif (isset($assertion['expected'])) {
|
|
// expected 값이 있으면 직접 비교
|
|
$expect['jsonPath'][$path] = $assertion['expected'];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $expect;
|
|
}
|
|
}
|