2025-11-27 19:20:07 +09:00
|
|
|
<?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, '@')) {
|
2025-12-04 15:30:04 +09:00
|
|
|
// 숫자 비교: 둘 다 숫자(또는 숫자 문자열)인 경우 타입 무관하게 비교
|
|
|
|
|
// 예: "2" == 2, "123" == 123
|
|
|
|
|
if ($this->areNumericEqual($actual, $expected)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-27 19:20:07 +09:00
|
|
|
if ($actual !== $expected) {
|
|
|
|
|
return sprintf(
|
2025-11-27 22:20:36 +09:00
|
|
|
'Path %s: expected %s, got %s',
|
2025-11-27 19:20:07 +09:00
|
|
|
$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).']';
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 15:30:04 +09:00
|
|
|
/**
|
|
|
|
|
* 숫자 값 비교 (타입 무관)
|
|
|
|
|
*
|
|
|
|
|
* 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다.
|
|
|
|
|
* 예: "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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-27 19:20:07 +09:00
|
|
|
/**
|
|
|
|
|
* 응답에서 값 추출 (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;
|
|
|
|
|
}
|
|
|
|
|
}
|