일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
582 lines
18 KiB
PHP
582 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Seo\DataSourceResolver;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* DataSourceResolver 단위 테스트
|
|
*
|
|
* 내부 API 호출을 통한 데이터 소스 해석 기능을 테스트합니다.
|
|
* Http::fake()를 사용하여 외부 HTTP 호출을 모킹합니다.
|
|
*/
|
|
class DataSourceResolverTest extends TestCase
|
|
{
|
|
private DataSourceResolver $resolver;
|
|
|
|
/**
|
|
* 테스트 초기화 - DataSourceResolver 인스턴스를 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->resolver = new DataSourceResolver;
|
|
}
|
|
|
|
/**
|
|
* 정상 API 호출 시 데이터 소스 ID에 응답 데이터가 매핑됩니다.
|
|
*/
|
|
public function test_normal_api_call_maps_data_to_ds_id(): void
|
|
{
|
|
$responseData = ['data' => ['id' => 1, 'name' => '상품A']];
|
|
|
|
Http::fake([
|
|
'*/api/products/123' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/123', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertArrayHasKey('product', $result);
|
|
$this->assertSame($responseData, $result['product']);
|
|
}
|
|
|
|
/**
|
|
* API 타임아웃 발생 시 fallback 데이터를 반환합니다.
|
|
*/
|
|
public function test_api_timeout_returns_fallback_data(): void
|
|
{
|
|
Http::fake([
|
|
'*' => function () {
|
|
throw new ConnectionException('Connection timed out');
|
|
},
|
|
]);
|
|
|
|
$fallback = ['data' => ['name' => '기본값']];
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/1', 'method' => 'GET', 'fallback' => $fallback],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertSame($fallback, $result['product']);
|
|
}
|
|
|
|
/**
|
|
* API 404 응답 시 fallback 데이터를 반환합니다.
|
|
*/
|
|
public function test_api_404_returns_fallback_data(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['error' => 'Not Found'], 404),
|
|
]);
|
|
|
|
$fallback = ['data' => []];
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/999', 'method' => 'GET', 'fallback' => $fallback],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertSame($fallback, $result['product']);
|
|
}
|
|
|
|
/**
|
|
* API 500 응답 시 fallback 데이터를 반환합니다.
|
|
*/
|
|
public function test_api_500_returns_fallback_data(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['error' => 'Server Error'], 500),
|
|
]);
|
|
|
|
$fallback = ['data' => ['default' => true]];
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/1', 'method' => 'GET', 'fallback' => $fallback],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertSame($fallback, $result['product']);
|
|
}
|
|
|
|
/**
|
|
* 라우트 파라미터 {{route.id}} 패턴이 실제 값으로 치환됩니다.
|
|
*/
|
|
public function test_route_params_substitution(): void
|
|
{
|
|
$responseData = ['data' => ['id' => 456, 'name' => '상품B']];
|
|
|
|
Http::fake([
|
|
'*/api/products/456' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/{{route.id}}', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], ['id' => '456'], 'ko');
|
|
|
|
$this->assertArrayHasKey('product', $result);
|
|
$this->assertSame($responseData, $result['product']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), '/api/products/456');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* seoDataSourceIds에 포함되지 않은 데이터 소스는 호출하지 않습니다.
|
|
*/
|
|
public function test_ds_id_not_in_seo_data_source_ids_is_not_called(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/1', 'method' => 'GET'],
|
|
['id' => 'sidebar', 'endpoint' => '/api/sidebar', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertArrayHasKey('product', $result);
|
|
$this->assertArrayNotHasKey('sidebar', $result);
|
|
|
|
Http::assertSentCount(1);
|
|
}
|
|
|
|
/**
|
|
* Accept-Language 헤더가 로케일 값으로 전달됩니다.
|
|
*/
|
|
public function test_accept_language_header_passed(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/1', 'method' => 'GET'],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['product'], [], 'en');
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->header('Accept-Language')[0] === 'en'
|
|
&& $request->header('Accept')[0] === 'application/json';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 쿼리 파라미터가 포함된 엔드포인트를 정상 호출합니다.
|
|
*/
|
|
public function test_query_params_in_endpoint(): void
|
|
{
|
|
$responseData = ['data' => ['items' => []]];
|
|
|
|
Http::fake([
|
|
'*' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'search', 'endpoint' => '/api/products?category=shoes&page=1', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['search'], [], 'ko');
|
|
|
|
$this->assertSame($responseData, $result['search']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'category=shoes')
|
|
&& str_contains($request->url(), 'page=1');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 빈 엔드포인트는 건너뜁니다.
|
|
*/
|
|
public function test_empty_endpoint_skipped(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertArrayNotHasKey('product', $result);
|
|
Http::assertNothingSent();
|
|
}
|
|
|
|
/**
|
|
* 여러 라우트 파라미터가 동시에 치환됩니다.
|
|
*/
|
|
public function test_multiple_route_params_substituted(): void
|
|
{
|
|
$responseData = ['data' => ['id' => 10]];
|
|
|
|
Http::fake([
|
|
'*' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'item', 'endpoint' => '/api/categories/{{route.category}}/products/{{route.id}}', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['item'], ['category' => 'shoes', 'id' => '10'], 'ko');
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), '/api/categories/shoes/products/10');
|
|
});
|
|
|
|
$this->assertSame($responseData, $result['item']);
|
|
}
|
|
|
|
/**
|
|
* fallback이 정의되지 않은 경우 빈 배열을 반환합니다.
|
|
*/
|
|
public function test_no_fallback_defined_returns_empty_array(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(null, 500),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'product', 'endpoint' => '/api/products/1', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['product'], [], 'ko');
|
|
|
|
$this->assertSame([], $result['product']);
|
|
}
|
|
|
|
/**
|
|
* optional chaining {{route?.slug}} 패턴이 정상 치환됩니다.
|
|
*/
|
|
public function test_optional_chaining_route_params_substitution(): void
|
|
{
|
|
$responseData = ['data' => ['id' => 1, 'title' => '페이지']];
|
|
|
|
Http::fake([
|
|
'*/api/pages/my-page' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'page', 'endpoint' => '/api/pages/{{route?.slug}}', 'method' => 'GET'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['page'], ['slug' => 'my-page'], 'ko');
|
|
|
|
$this->assertArrayHasKey('page', $result);
|
|
$this->assertSame($responseData, $result['page']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), '/api/pages/my-page');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST 메서드 데이터 소스를 호출합니다.
|
|
*/
|
|
public function test_post_method_data_source(): void
|
|
{
|
|
$responseData = ['data' => ['results' => []]];
|
|
|
|
Http::fake([
|
|
'*' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
['id' => 'search', 'endpoint' => '/api/search', 'method' => 'POST'],
|
|
];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['search'], [], 'ko');
|
|
|
|
$this->assertSame($responseData, $result['search']);
|
|
}
|
|
|
|
// =========================================================================
|
|
// params 해석 + 쿼리 파라미터 전달 테스트
|
|
// =========================================================================
|
|
|
|
/**
|
|
* params의 {{query.xxx}} 표현식이 쿼리 파라미터에서 해석됩니다.
|
|
*/
|
|
public function test_params_resolve_query_expressions(): void
|
|
{
|
|
$responseData = ['data' => [['id' => 1]]];
|
|
|
|
Http::fake([
|
|
'*' => Http::response($responseData, 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => [
|
|
'page' => '{{query.page ?? 1}}',
|
|
'per_page' => 12,
|
|
'sort' => '{{query.sort ?? \'latest\'}}',
|
|
],
|
|
],
|
|
];
|
|
|
|
$queryParams = ['page' => '2', 'sort' => 'price_asc'];
|
|
|
|
$result = $this->resolver->resolve($dataSources, ['products'], [], 'ko', $queryParams);
|
|
|
|
$this->assertSame($responseData, $result['products']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
$url = $request->url();
|
|
|
|
return str_contains($url, 'page=2')
|
|
&& str_contains($url, 'per_page=12')
|
|
&& str_contains($url, 'sort=price_asc');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* params의 {{route.xxx}} 표현식이 라우트 파라미터에서 해석됩니다.
|
|
*/
|
|
public function test_params_resolve_route_expressions(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => [
|
|
'category_slug' => '{{route.slug}}',
|
|
],
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['products'], ['slug' => 'shoes'], 'ko');
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'category_slug=shoes');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* params에서 빈 문자열로 해석된 선택적 파라미터는 전달되지 않습니다.
|
|
*/
|
|
public function test_params_empty_optional_param_not_sent(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => [
|
|
'search' => "{{query.keyword ?? ''}}",
|
|
'page' => '{{query.page ?? 1}}',
|
|
],
|
|
],
|
|
];
|
|
|
|
// keyword 없음 → search 파라미터 미전달
|
|
$this->resolver->resolve($dataSources, ['products'], [], 'ko', []);
|
|
|
|
Http::assertSent(function ($request) {
|
|
$url = $request->url();
|
|
|
|
return str_contains($url, 'page=1')
|
|
&& ! str_contains($url, 'search=');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* params가 비어있으면 쿼리 파라미터 없이 호출합니다.
|
|
*/
|
|
public function test_params_empty_no_query_string(): void
|
|
{
|
|
Http::fake([
|
|
'*/api/products' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => [],
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['products'], [], 'ko', ['page' => '2']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
// params가 빈 배열이면 query string 미추가
|
|
return ! str_contains($request->url(), '?');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 숫자 리터럴 params는 그대로 전달됩니다.
|
|
*/
|
|
public function test_params_numeric_literal_passed_as_is(): void
|
|
{
|
|
Http::fake([
|
|
'*' => Http::response(['data' => []], 200),
|
|
]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => [
|
|
'per_page' => 12,
|
|
'page' => '{{query.page ?? 1}}',
|
|
],
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['products'], [], 'ko', ['page' => '3']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'per_page=12')
|
|
&& str_contains($request->url(), 'page=3');
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// 엔드포인트 표현식 — route 외 컨텍스트 (query / _global)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 엔드포인트 안의 {{query.xxx}} 표현식이 쿼리 파라미터로 치환됩니다.
|
|
*/
|
|
public function test_endpoint_resolves_query_expression(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['data' => []], 200)]);
|
|
|
|
$dataSources = [
|
|
['id' => 'searchResults', 'endpoint' => '/api/search?q={{query?.q}}', 'method' => 'GET'],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['searchResults'], [], 'ko', ['q' => '검색어']);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains(urldecode($request->url()), 'q=검색어');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 엔드포인트 안의 {{_global.xxx}} 표현식이 전달된 컨텍스트로 치환됩니다.
|
|
*/
|
|
public function test_endpoint_resolves_global_expression(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['data' => []], 200)]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'searchResults',
|
|
'endpoint' => "/api/search?type={{_global.searchActiveTab ?? 'all'}}&page={{_global.searchPage ?? 1}}",
|
|
'method' => 'GET',
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve(
|
|
$dataSources,
|
|
['searchResults'],
|
|
[],
|
|
'ko',
|
|
[],
|
|
['_global' => ['searchActiveTab' => 'products', 'searchPage' => 2]]
|
|
);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'type=products')
|
|
&& str_contains($request->url(), 'page=2');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 전역 컨텍스트가 없으면 엔드포인트의 ?? 기본값이 적용됩니다.
|
|
*/
|
|
public function test_endpoint_global_expression_falls_back_to_default(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['data' => []], 200)]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'searchResults',
|
|
'endpoint' => "/api/search?type={{_global.searchActiveTab ?? 'all'}}",
|
|
'method' => 'GET',
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['searchResults'], [], 'ko');
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'type=all'));
|
|
}
|
|
|
|
/**
|
|
* 엔드포인트에 표현식이 없으면 원본 그대로 사용됩니다 (회귀 방지).
|
|
*/
|
|
public function test_endpoint_without_expression_is_unchanged(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['data' => []], 200)]);
|
|
|
|
$dataSources = [
|
|
['id' => 'categories', 'endpoint' => '/api/categories', 'method' => 'GET'],
|
|
];
|
|
|
|
$this->resolver->resolve($dataSources, ['categories'], [], 'ko');
|
|
|
|
Http::assertSent(fn ($request) => str_ends_with($request->url(), '/api/categories'));
|
|
}
|
|
|
|
/**
|
|
* params 표현식에서도 _global 컨텍스트를 참조할 수 있습니다.
|
|
*/
|
|
public function test_params_resolve_global_expression(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['data' => []], 200)]);
|
|
|
|
$dataSources = [
|
|
[
|
|
'id' => 'products',
|
|
'endpoint' => '/api/products',
|
|
'method' => 'GET',
|
|
'params' => ['currency' => "{{_global.preferredCurrency ?? 'KRW'}}"],
|
|
],
|
|
];
|
|
|
|
$this->resolver->resolve(
|
|
$dataSources,
|
|
['products'],
|
|
[],
|
|
'ko',
|
|
[],
|
|
['_global' => ['preferredCurrency' => 'USD']]
|
|
);
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'currency=USD'));
|
|
}
|
|
}
|