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; } // JSONPath 배열 인덱스 표기법 [n]을 Laravel dot notation .n으로 변환 // 예: data.data[0].id → data.data.0.id $path = preg_replace('/\[(\d+)\]/', '.$1', $path); return data_get($data, $path); } /** * 개별 값 검증 */ private function validateValue(mixed $actual, mixed $expected, string $path): ?string { // 직접 값 비교 (연산자가 아닌 경우) 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', $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 === 'isObject' => ! is_array($actual) || array_is_list($actual) ? "Path {$path}: expected object, got ".(is_array($actual) ? 'array' : 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).']'; } /** * 숫자 값 비교 (타입 무관) * * 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다. * 예: "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 처리) * * @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; } }