- VariableBinder: setVariables()에서 {{$env.XXX}} 환경변수 치환 지원
- FlowExecutor: 스텝 실행 결과에 reason, description, expect 필드 추가
- buildResultReason(): 성공/실패 이유 자동 생성
- 부정 테스트(400, 404 등) 시 예상대로 반환됨을 명시
- 400: 유효성 검증 실패 확인
- 404: 리소스 미존재 확인
- 409: 충돌 상태 확인
- 403: 권한 거부 확인
- run-detail.blade.php: 예상 상태코드, 이유, 스텝 설명 표시 UI
419 lines
13 KiB
PHP
419 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services\FlowTester;
|
|
|
|
use Faker\Factory as FakerFactory;
|
|
use Faker\Generator as FakerGenerator;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* 변수 바인딩 엔진
|
|
*
|
|
* {{...}} 패턴을 감지하고 실제 값으로 치환합니다.
|
|
* - {{variables.xxx}} - 전역 변수
|
|
* - {{stepN.xxx}} - 이전 단계 추출값
|
|
* - {{stepN.response.xxx}} - 이전 단계 전체 응답
|
|
* - {{$timestamp}} - 현재 타임스탬프
|
|
* - {{$uuid}} - 랜덤 UUID
|
|
* - {{$random:N}} - N자리 랜덤 숫자
|
|
* - {{$faker.xxx}} - Faker 기반 랜덤 데이터 생성
|
|
*/
|
|
class VariableBinder
|
|
{
|
|
private array $context = [];
|
|
|
|
private ?FakerGenerator $faker = null;
|
|
|
|
/**
|
|
* 컨텍스트 초기화
|
|
*/
|
|
public function reset(): void
|
|
{
|
|
$this->context = [];
|
|
}
|
|
|
|
/**
|
|
* 전역 변수 설정
|
|
* 환경변수 참조 ({{$env.XXX}})도 이 시점에 치환
|
|
*/
|
|
public function setVariables(array $variables): void
|
|
{
|
|
// 환경변수 참조를 먼저 치환
|
|
$resolvedVariables = [];
|
|
foreach ($variables as $key => $value) {
|
|
if (is_string($value)) {
|
|
$resolvedVariables[$key] = $this->resolveBuiltins($value);
|
|
} else {
|
|
$resolvedVariables[$key] = $value;
|
|
}
|
|
}
|
|
$this->context['variables'] = $resolvedVariables;
|
|
}
|
|
|
|
/**
|
|
* 컨텍스트에 변수 추가
|
|
*/
|
|
public function setVariable(string $key, mixed $value): void
|
|
{
|
|
data_set($this->context, $key, $value);
|
|
}
|
|
|
|
/**
|
|
* 단계 결과 저장
|
|
*/
|
|
public function setStepResult(string $stepId, array $extracted, array $fullResponse): void
|
|
{
|
|
$this->context['steps'][$stepId] = [
|
|
'extracted' => $extracted,
|
|
'response' => $fullResponse,
|
|
];
|
|
|
|
// 편의를 위해 extracted 값을 stepId.key 형태로도 접근 가능하게
|
|
foreach ($extracted as $key => $value) {
|
|
$this->context['steps'][$stepId][$key] = $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 문자열/배열 내 모든 변수 치환
|
|
*/
|
|
public function bind(mixed $input): mixed
|
|
{
|
|
if (is_string($input)) {
|
|
return $this->bindString($input);
|
|
}
|
|
|
|
if (is_array($input)) {
|
|
return array_map(fn ($v) => $this->bind($v), $input);
|
|
}
|
|
|
|
return $input;
|
|
}
|
|
|
|
/**
|
|
* 문자열 내 변수 패턴 치환
|
|
*/
|
|
private function bindString(string $input): string
|
|
{
|
|
// 내장 변수 처리
|
|
$input = $this->resolveBuiltins($input);
|
|
|
|
// 컨텍스트 변수 처리
|
|
return preg_replace_callback(
|
|
'/\{\{([^}]+)\}\}/',
|
|
fn ($matches) => $this->resolveReference($matches[1]),
|
|
$input
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 내장 변수 처리 ($timestamp, $uuid, $random:N, $env.XXX, $auth.token)
|
|
*/
|
|
private function resolveBuiltins(string $input): string
|
|
{
|
|
// {{$timestamp}} → 현재 타임스탬프
|
|
$input = str_replace('{{$timestamp}}', (string) time(), $input);
|
|
|
|
// {{$uuid}} → 랜덤 UUID
|
|
$input = str_replace('{{$uuid}}', (string) Str::uuid(), $input);
|
|
|
|
// {{$random:N}} → N자리 랜덤 숫자
|
|
$input = preg_replace_callback(
|
|
'/\{\{\$random:(\d+)\}\}/',
|
|
function ($m) {
|
|
$digits = (int) $m[1];
|
|
$max = (int) pow(10, $digits) - 1;
|
|
|
|
return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT);
|
|
},
|
|
$input
|
|
);
|
|
|
|
// {{$date}} → 현재 날짜 (Y-m-d)
|
|
$input = str_replace('{{$date}}', date('Y-m-d'), $input);
|
|
|
|
// {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s)
|
|
$input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input);
|
|
|
|
// {{$env.VAR_NAME}} → 환경변수에서 읽기
|
|
$input = preg_replace_callback(
|
|
'/\{\{\$env\.([A-Z_][A-Z0-9_]*)\}\}/i',
|
|
fn ($m) => env($m[1], ''),
|
|
$input
|
|
);
|
|
|
|
// {{$auth.token}} → 현재 로그인 사용자의 API 토큰
|
|
if (str_contains($input, '{{$auth.token}}')) {
|
|
$token = $this->getAuthToken();
|
|
$input = str_replace('{{$auth.token}}', $token, $input);
|
|
}
|
|
|
|
// {{$auth.apiKey}} → .env의 API Key
|
|
$input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input);
|
|
|
|
// {{$faker.xxx}} → Faker 기반 랜덤 데이터 생성
|
|
$input = $this->resolveFaker($input);
|
|
|
|
return $input;
|
|
}
|
|
|
|
/**
|
|
* Faker 인스턴스 가져오기 (지연 초기화)
|
|
*/
|
|
private function getFaker(): FakerGenerator
|
|
{
|
|
if ($this->faker === null) {
|
|
$this->faker = FakerFactory::create('ko_KR');
|
|
}
|
|
|
|
return $this->faker;
|
|
}
|
|
|
|
/**
|
|
* Faker 변수 처리
|
|
*
|
|
* 지원 패턴:
|
|
* - {{$faker.name}} - 랜덤 이름
|
|
* - {{$faker.company}} - 랜덤 회사명
|
|
* - {{$faker.email}} - 랜덤 이메일
|
|
* - {{$faker.phone}} - 랜덤 전화번호
|
|
* - {{$faker.word}} - 랜덤 단어
|
|
* - {{$faker.sentence}} - 랜덤 문장
|
|
* - {{$faker.text}} - 랜덤 텍스트
|
|
* - {{$faker.number:MIN:MAX}} - 범위 내 랜덤 정수
|
|
* - {{$faker.price:MIN:MAX}} - 범위 내 랜덤 가격 (소수점 2자리)
|
|
* - {{$faker.code:PREFIX:LENGTH}} - 코드 생성 (PREFIX + 숫자)
|
|
* - {{$faker.itemCode:PREFIX}} - 품목코드 생성 (PREFIX + 6자리 숫자)
|
|
* - {{$faker.address}} - 랜덤 주소
|
|
* - {{$faker.url}} - 랜덤 URL
|
|
* - {{$faker.boolean}} - true/false
|
|
*/
|
|
private function resolveFaker(string $input): string
|
|
{
|
|
// {{$faker.xxx}} 또는 {{$faker.xxx:param1:param2}} 패턴 처리
|
|
return preg_replace_callback(
|
|
'/\{\{\$faker\.([a-zA-Z]+)(?::([^}]*))?\}\}/',
|
|
fn ($m) => $this->generateFakerValue($m[1], $m[2] ?? ''),
|
|
$input
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Faker 값 생성
|
|
*/
|
|
private function generateFakerValue(string $type, string $params): string
|
|
{
|
|
$faker = $this->getFaker();
|
|
$paramList = $params !== '' ? explode(':', $params) : [];
|
|
|
|
return match ($type) {
|
|
// 기본 텍스트
|
|
'name' => $faker->name(),
|
|
'firstName' => $faker->firstName(),
|
|
'lastName' => $faker->lastName(),
|
|
'company' => $faker->company(),
|
|
'email' => $faker->unique()->safeEmail(),
|
|
'phone' => $faker->phoneNumber(),
|
|
'word' => $faker->word(),
|
|
'sentence' => $faker->sentence(),
|
|
'text' => $faker->text(100),
|
|
'paragraph' => $faker->paragraph(),
|
|
|
|
// 주소
|
|
'address' => $faker->address(),
|
|
'city' => $faker->city(),
|
|
'postcode' => $faker->postcode(),
|
|
|
|
// 숫자
|
|
'number' => $this->fakerNumber($faker, $paramList),
|
|
'price' => $this->fakerPrice($faker, $paramList),
|
|
'quantity' => (string) $faker->numberBetween(1, 100),
|
|
|
|
// 코드 생성
|
|
'code' => $this->fakerCode($paramList),
|
|
'itemCode' => $this->fakerItemCode($paramList),
|
|
'sku' => 'SKU-'.strtoupper($faker->bothify('??###')),
|
|
'barcode' => $faker->ean13(),
|
|
|
|
// 기타
|
|
'url' => $faker->url(),
|
|
'boolean' => $faker->boolean() ? 'true' : 'false',
|
|
'uuid' => $faker->uuid(),
|
|
'date' => $faker->date('Y-m-d'),
|
|
'datetime' => $faker->dateTime()->format('Y-m-d H:i:s'),
|
|
|
|
// 품목 관련
|
|
'productName' => $this->fakerProductName($faker),
|
|
'unit' => $faker->randomElement(['EA', 'SET', 'BOX', 'KG', 'M', 'L', 'PCS']),
|
|
'category' => $faker->randomElement(['원자재', '부품', '반제품', '완제품', '소모품']),
|
|
|
|
default => "{{faker.{$type}}}",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 범위 내 랜덤 정수 생성
|
|
*/
|
|
private function fakerNumber(FakerGenerator $faker, array $params): string
|
|
{
|
|
$min = isset($params[0]) ? (int) $params[0] : 1;
|
|
$max = isset($params[1]) ? (int) $params[1] : 1000;
|
|
|
|
return (string) $faker->numberBetween($min, $max);
|
|
}
|
|
|
|
/**
|
|
* 범위 내 랜덤 가격 생성 (소수점 2자리)
|
|
*/
|
|
private function fakerPrice(FakerGenerator $faker, array $params): string
|
|
{
|
|
$min = isset($params[0]) ? (float) $params[0] : 1000;
|
|
$max = isset($params[1]) ? (float) $params[1] : 100000;
|
|
|
|
return number_format($faker->randomFloat(2, $min, $max), 2, '.', '');
|
|
}
|
|
|
|
/**
|
|
* 커스텀 코드 생성 (PREFIX + 숫자)
|
|
*/
|
|
private function fakerCode(array $params): string
|
|
{
|
|
$prefix = $params[0] ?? 'CODE';
|
|
$length = isset($params[1]) ? (int) $params[1] : 6;
|
|
|
|
$digits = str_pad((string) random_int(0, (int) pow(10, $length) - 1), $length, '0', STR_PAD_LEFT);
|
|
|
|
return $prefix.$digits;
|
|
}
|
|
|
|
/**
|
|
* 품목코드 생성 (PREFIX + 6자리 숫자)
|
|
*/
|
|
private function fakerItemCode(array $params): string
|
|
{
|
|
$prefix = $params[0] ?? 'ITEM';
|
|
$digits = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
|
|
return $prefix.'-'.$digits;
|
|
}
|
|
|
|
/**
|
|
* 제품명 생성 (한글)
|
|
*/
|
|
private function fakerProductName(FakerGenerator $faker): string
|
|
{
|
|
$prefixes = ['고급', '프리미엄', '스탠다드', '베이직', '프로', '울트라'];
|
|
$types = ['부품', '자재', '모듈', '키트', '세트', '패키지'];
|
|
$models = ['A', 'B', 'C', 'X', 'Y', 'Z', 'Pro', 'Plus', 'Max'];
|
|
|
|
return $faker->randomElement($prefixes).' '
|
|
.$faker->randomElement($types).' '
|
|
.$faker->randomElement($models).'-'
|
|
.$faker->numberBetween(100, 999);
|
|
}
|
|
|
|
/**
|
|
* 현재 로그인 사용자의 API 토큰 조회
|
|
*/
|
|
private function getAuthToken(): string
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user) {
|
|
return env('FLOW_TESTER_API_TOKEN', '');
|
|
}
|
|
|
|
// 사용자에게 저장된 API 토큰이 있으면 사용
|
|
if (! empty($user->api_token)) {
|
|
return $user->api_token;
|
|
}
|
|
|
|
// 없으면 .env의 기본 토큰 사용
|
|
return env('FLOW_TESTER_API_TOKEN', '');
|
|
}
|
|
|
|
/**
|
|
* 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값)
|
|
*
|
|
* 지원 패턴:
|
|
* - {{variables.xxx}} - 명시적 변수 참조
|
|
* - {{xxx}} - 단축형 변수 참조 (variables.xxx로 폴백)
|
|
* - {{stepId.xxx}} - 스텝 추출값 참조
|
|
* - {{stepId.response.xxx}} - 스텝 전체 응답 참조
|
|
*/
|
|
private function resolveReference(string $path): string
|
|
{
|
|
$path = trim($path);
|
|
|
|
// variables.xxx → $this->context['variables']['xxx']
|
|
if (str_starts_with($path, 'variables.')) {
|
|
$value = data_get($this->context, $path, '');
|
|
|
|
return $this->valueToString($value);
|
|
}
|
|
|
|
// stepId.xxx 패턴 감지 (stepId는 등록된 step의 ID)
|
|
// 첫 번째 점(.) 기준으로 분리
|
|
$dotPos = strpos($path, '.');
|
|
if ($dotPos !== false) {
|
|
$potentialStepId = substr($path, 0, $dotPos);
|
|
$subPath = substr($path, $dotPos + 1);
|
|
|
|
// 등록된 step인지 확인
|
|
if (isset($this->context['steps'][$potentialStepId])) {
|
|
// stepId.response.xxx → 전체 응답에서 추출
|
|
if (str_starts_with($subPath, 'response.')) {
|
|
$responsePath = substr($subPath, 9); // "response." 제거
|
|
$value = data_get($this->context['steps'][$potentialStepId]['response'] ?? [], $responsePath, '');
|
|
|
|
return $this->valueToString($value);
|
|
}
|
|
|
|
// stepId.xxx → extracted 또는 직접 접근
|
|
$value = data_get($this->context['steps'][$potentialStepId] ?? [], $subPath, '');
|
|
|
|
return $this->valueToString($value);
|
|
}
|
|
}
|
|
|
|
// 기타 경로는 context에서 직접 조회
|
|
$value = data_get($this->context, $path, null);
|
|
|
|
// 값이 없으면 variables.xxx로 폴백 시도 (단축형 지원)
|
|
// 예: {{user_id}} → variables.user_id
|
|
if ($value === null && ! str_contains($path, '.')) {
|
|
$value = data_get($this->context, 'variables.'.$path, '');
|
|
}
|
|
|
|
return $this->valueToString($value ?? '');
|
|
}
|
|
|
|
/**
|
|
* 값을 문자열로 변환 (배열/객체는 JSON)
|
|
*/
|
|
private function valueToString(mixed $value): string
|
|
{
|
|
if (is_null($value)) {
|
|
return '';
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value ? 'true' : 'false';
|
|
}
|
|
|
|
if (is_array($value) || is_object($value)) {
|
|
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
|
|
/**
|
|
* 현재 컨텍스트 반환 (디버깅용)
|
|
*/
|
|
public function getContext(): array
|
|
{
|
|
return $this->context;
|
|
}
|
|
}
|