Files
sam-manage/app/Services/FlowTester/ResponseValidator.php
hskwon fe902472c1 feat: [flow-tester] 의존성 검사, Docker 지원, 인증 자동 주입 기능 추가
FlowExecutor 개선:
- 의존성 스텝 실패 시 후속 스텝 자동 스킵 로직 추가
- Docker 환경 자동 감지 및 내부 URL 변환 (api.sam.kr → nginx)
- SSL 검증 비활성화 및 Host 헤더 설정 지원
- .env에서 API Key/Bearer Token 자동 주입

VariableBinder 개선:
- 임의 stepId 패턴 지원 (page_create_1.tempPageId 등)
- {{$env.VAR_NAME}} 환경변수 플레이스홀더 추가
- {{$auth.token}}, {{$auth.apiKey}} 인증 플레이스홀더 추가

UI 개선:
- SKIPPED 상태 스타일링 (노란색 배경/테두리)
- 행 클릭 시 스텝 상세 확장 기능
- 실행 결과 실시간 표시 개선
2025-11-27 22:20:36 +09:00

287 lines
9.7 KiB
PHP

<?php
namespace App\Services\FlowTester;
/**
* 응답 검증기
*
* HTTP 응답을 기대값(expect)과 비교하여 검증합니다.
*
* 지원 연산자:
* - 직접 값: "$.success": true
* - @exists: 존재 체크
* - @isNumber: 숫자 타입 체크
* - @isString: 문자열 타입 체크
* - @isArray: 배열 타입 체크
* - @isBoolean: 불리언 타입 체크
* - @minLength:N: 최소 길이 (배열/문자열)
* - @maxLength:N: 최대 길이 (배열/문자열)
* - @regex:pattern: 정규식 매칭
* - @gt:N: N보다 큼
* - @gte:N: N 이상
* - @lt:N: N보다 작음
* - @lte:N: N 이하
*/
class ResponseValidator
{
/**
* 응답 검증
*
* @param array $response HTTP 응답 ['status' => int, 'body' => array]
* @param array $expect 기대값 정의
* @return array ['success' => bool, 'errors' => array]
*/
public function validate(array $response, array $expect): array
{
$errors = [];
// 상태 코드 검증
if (isset($expect['status'])) {
$allowedStatus = (array) $expect['status'];
$actualStatus = $response['status'] ?? 0;
if (! in_array($actualStatus, $allowedStatus)) {
$errors[] = sprintf(
'Expected status %s, got %d',
$this->formatList($allowedStatus),
$actualStatus
);
}
}
// JSONPath 검증
if (isset($expect['jsonPath'])) {
foreach ($expect['jsonPath'] as $path => $expected) {
$actual = $this->getValueByPath($response['body'] ?? [], $path);
$pathError = $this->validateValue($actual, $expected, $path);
if ($pathError) {
$errors[] = $pathError;
}
}
}
return [
'success' => empty($errors),
'errors' => $errors,
];
}
/**
* JSONPath로 값 추출
*
* @param array $data 데이터
* @param string $path 경로 ($.data.id 형식)
*/
private function getValueByPath(array $data, string $path): mixed
{
// $. 접두사 제거
$path = ltrim($path, '$.');
if (empty($path)) {
return $data;
}
return data_get($data, $path);
}
/**
* 개별 값 검증
*/
private function validateValue(mixed $actual, mixed $expected, string $path): ?string
{
// 직접 값 비교 (연산자가 아닌 경우)
if (! is_string($expected) || ! str_starts_with($expected, '@')) {
if ($actual !== $expected) {
return sprintf(
'Path %s: expected %s, got %s',
$path,
json_encode($expected, JSON_UNESCAPED_UNICODE),
json_encode($actual, JSON_UNESCAPED_UNICODE)
);
}
return null;
}
// 연산자 처리
$operator = substr($expected, 1);
return match (true) {
$operator === 'exists' => $actual === null ? "Path {$path}: expected to exist" : null,
$operator === 'notExists' => $actual !== null ? "Path {$path}: expected to not exist" : null,
$operator === 'isNumber' => ! is_numeric($actual) ? "Path {$path}: expected number, got ".gettype($actual) : null,
$operator === 'isString' => ! is_string($actual) ? "Path {$path}: expected string, got ".gettype($actual) : null,
$operator === 'isArray' => ! is_array($actual) ? "Path {$path}: expected array, got ".gettype($actual) : null,
$operator === 'isBoolean' => ! is_bool($actual) ? "Path {$path}: expected boolean, got ".gettype($actual) : null,
$operator === 'isNull' => $actual !== null ? "Path {$path}: expected null, got ".gettype($actual) : null,
$operator === 'notNull' => $actual === null ? "Path {$path}: expected not null" : null,
$operator === 'isEmpty' => ! empty($actual) ? "Path {$path}: expected empty" : null,
$operator === 'notEmpty' => empty($actual) ? "Path {$path}: expected not empty" : null,
str_starts_with($operator, 'minLength:') => $this->validateMinLength($actual, $operator, $path),
str_starts_with($operator, 'maxLength:') => $this->validateMaxLength($actual, $operator, $path),
str_starts_with($operator, 'regex:') => $this->validateRegex($actual, $operator, $path),
str_starts_with($operator, 'gt:') => $this->validateGreaterThan($actual, $operator, $path),
str_starts_with($operator, 'gte:') => $this->validateGreaterThanOrEqual($actual, $operator, $path),
str_starts_with($operator, 'lt:') => $this->validateLessThan($actual, $operator, $path),
str_starts_with($operator, 'lte:') => $this->validateLessThanOrEqual($actual, $operator, $path),
str_starts_with($operator, 'contains:') => $this->validateContains($actual, $operator, $path),
default => "Path {$path}: unknown operator @{$operator}",
};
}
private function validateMinLength(mixed $actual, string $operator, string $path): ?string
{
$min = (int) substr($operator, 10); // "minLength:" 길이
if (is_array($actual)) {
if (count($actual) < $min) {
return "Path {$path}: expected array with min length {$min}, got ".count($actual);
}
} elseif (is_string($actual)) {
if (mb_strlen($actual) < $min) {
return "Path {$path}: expected string with min length {$min}, got ".mb_strlen($actual);
}
} else {
return "Path {$path}: minLength requires array or string";
}
return null;
}
private function validateMaxLength(mixed $actual, string $operator, string $path): ?string
{
$max = (int) substr($operator, 10); // "maxLength:" 길이
if (is_array($actual)) {
if (count($actual) > $max) {
return "Path {$path}: expected array with max length {$max}, got ".count($actual);
}
} elseif (is_string($actual)) {
if (mb_strlen($actual) > $max) {
return "Path {$path}: expected string with max length {$max}, got ".mb_strlen($actual);
}
} else {
return "Path {$path}: maxLength requires array or string";
}
return null;
}
private function validateRegex(mixed $actual, string $operator, string $path): ?string
{
$pattern = '/'.substr($operator, 6).'/'; // "regex:" 제거
if (! is_string($actual)) {
return "Path {$path}: regex requires string value";
}
if (! preg_match($pattern, $actual)) {
return "Path {$path}: value does not match pattern {$pattern}";
}
return null;
}
private function validateGreaterThan(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 3); // "gt:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: gt requires numeric value";
}
if ($actual <= $threshold) {
return "Path {$path}: expected > {$threshold}, got {$actual}";
}
return null;
}
private function validateGreaterThanOrEqual(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 4); // "gte:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: gte requires numeric value";
}
if ($actual < $threshold) {
return "Path {$path}: expected >= {$threshold}, got {$actual}";
}
return null;
}
private function validateLessThan(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 3); // "lt:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: lt requires numeric value";
}
if ($actual >= $threshold) {
return "Path {$path}: expected < {$threshold}, got {$actual}";
}
return null;
}
private function validateLessThanOrEqual(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 4); // "lte:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: lte requires numeric value";
}
if ($actual > $threshold) {
return "Path {$path}: expected <= {$threshold}, got {$actual}";
}
return null;
}
private function validateContains(mixed $actual, string $operator, string $path): ?string
{
$needle = substr($operator, 9); // "contains:" 제거
if (is_string($actual)) {
if (! str_contains($actual, $needle)) {
return "Path {$path}: expected to contain '{$needle}'";
}
} elseif (is_array($actual)) {
if (! in_array($needle, $actual)) {
return "Path {$path}: expected array to contain '{$needle}'";
}
} else {
return "Path {$path}: contains requires string or array";
}
return null;
}
private function formatList(array $items): string
{
return '['.implode(', ', $items).']';
}
/**
* 응답에서 값 추출 (extract 처리)
*
* @param array $body 응답 바디
* @param array $extract 추출 정의 ['pageId' => '$.data.id']
* @return array 추출된 값들
*/
public function extractValues(array $body, array $extract): array
{
$result = [];
foreach ($extract as $name => $path) {
$result[$name] = $this->getValueByPath($body, $path);
}
return $result;
}
}