fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정

일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

- 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속
 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구
- 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화.
 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다
- 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 /
 children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태
- 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로,
 비교식이 raw 경로에서 null 이 되던 문제
- 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강
- 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
This commit is contained in:
HeuJung committed 2026-07-27 10:03:06 +09:00
1 parent b505ac7ba7
commit 0dfb45951b
34 files changed
+5244 -172

No files matched your search

+643 -1
View File
@@ -70,7 +70,8 @@ class ComponentHtmlMapperTest extends TestCase
'Th' => ['tag' => 'th'],
'Td' => ['tag' => 'td'],
'Label' => ['tag' => 'label'],
'Select' => ['tag' => 'select'],
'Select' => ['tag' => 'select', 'render' => 'select_options', 'props_source' => 'options'],
'Option' => ['tag' => 'option'],
'Textarea' => ['tag' => 'textarea'],
'Strong' => ['tag' => 'strong'],
'Em' => ['tag' => 'em'],
@@ -130,6 +131,13 @@ class ComponentHtmlMapperTest extends TestCase
'item_content' => '{label}',
'badge_field' => 'badge',
],
'select_options' => [
'type' => 'iterate',
'source' => '$props_source',
'item_tag' => 'option',
'item_attrs' => ['value' => '{value}'],
'item_content' => '{label|label_key|text|name}',
],
'text_format' => [
'type' => 'format',
],
@@ -2299,6 +2307,510 @@ class ComponentHtmlMapperTest extends TestCase
$this->assertStringContainsString('alt="기본 대체 텍스트"', $html);
}
// =========================================================================
// React 렌더러 패리티 — 노드 문법 (condition/conditions/iterator/text/responsive/if)
// =========================================================================
/**
* condition 속성이 if의 별칭으로 동작합니다.
*/
public function test_condition_property_acts_as_if_alias(): void
{
$shown = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '보임'],
], ['flag' => true]);
$hidden = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '숨김'],
], ['flag' => false]);
$this->assertStringContainsString('보임', $shown);
$this->assertSame('', $hidden);
}
/**
* conditions AND 그룹은 모든 조건이 참일 때만 렌더링됩니다.
*/
public function test_conditions_and_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false]));
}
/**
* conditions OR 그룹은 하나라도 참이면 렌더링됩니다.
*/
public function test_conditions_or_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['or' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => false, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => false, 'b' => false]));
}
/**
* conditions 중첩 그룹(AND 안의 OR)이 평가됩니다.
*/
public function test_conditions_nested_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', ['or' => ['{{b}}', '{{c}}']]]],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => false, 'c' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false, 'c' => false]));
}
/**
* conditions if/else 체인은 하나라도 매칭되면 렌더링됩니다.
*/
public function test_conditions_branch_chain(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => [
['if' => "{{role === 'admin'}}"],
['if' => "{{role === 'manager'}}"],
],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['role' => 'manager']));
$this->assertSame('', $this->render([$component], ['role' => 'guest']));
}
/**
* conditions if/else 체인의 else 브랜치(if 없음)는 항상 매칭됩니다.
*/
public function test_conditions_else_branch_always_matches(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'conditions' => [['if' => '{{never}}'], []],
'text' => '표시',
],
], ['never' => false]);
$this->assertStringContainsString('표시', $html);
}
/**
* 빈 AND 그룹은 true, 빈 OR 그룹은 false 로 평가됩니다 (React와 동일).
*/
public function test_conditions_empty_groups(): void
{
$and = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['and' => []], 'text' => 'AND'],
]);
$or = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['or' => []], 'text' => 'OR'],
]);
$this->assertStringContainsString('AND', $and);
$this->assertSame('', $or);
}
/**
* type: "iterator" 노드가 iteration 으로 변환되어 반복 렌더링됩니다.
*/
public function test_iterator_type_is_converted_to_iteration(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'itemName' => 'entry',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{entry.label}}'],
],
],
], ['items' => [['label' => '하나'], ['label' => '둘']]]);
$this->assertStringContainsString('하나', $html);
$this->assertStringContainsString('둘', $html);
}
/**
* type: "iterator" 의 itemName 을 생략하면 기본 별칭 item 이 사용됩니다.
*/
public function test_iterator_type_defaults_item_var(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{item.label}}'],
],
],
], ['items' => [['label' => '기본별칭']]]);
$this->assertStringContainsString('기본별칭', $html);
}
/**
* children 배열 내 문자열은 텍스트 노드로 출력됩니다.
*/
public function test_string_child_is_rendered_as_text_node(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => ['문자열 조각', ['type' => 'basic', 'name' => 'Span', 'text' => '요소']],
],
]);
$this->assertStringContainsString('문자열 조각', $html);
$this->assertStringContainsString('<span>요소</span>', $html);
}
/**
* children 배열 내 문자열은 HTML 이스케이프되어 출력됩니다.
*/
public function test_string_child_is_escaped(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['<b>굵게</b>']],
]);
$this->assertStringContainsString('&lt;b&gt;', $html);
$this->assertStringNotContainsString('<b>', $html);
}
/**
* children 배열 내 문자열의 표현식은 해석하지 않고 리터럴로 둡니다.
*
* React DynamicRenderer 가 문자열 자식을 그대로 반환하므로(표현식 미해석),
* SEO 가 해석하면 반대 방향의 패리티 격차가 생깁니다.
*/
public function test_string_child_expression_is_not_resolved(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['{{value}}']],
], ['value' => '해석되면 안 됨']);
$this->assertStringContainsString('{{value}}', $html);
$this->assertStringNotContainsString('해석되면 안 됨', $html);
}
/**
* text 와 children 이 함께 있으면 text 가 우선합니다 (React와 동일 우선순위).
*/
public function test_node_text_takes_precedence_over_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '텍스트 우선',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식']],
],
]);
$this->assertStringContainsString('텍스트 우선', $html);
$this->assertStringNotContainsString('자식', $html);
}
/**
* text 키가 있으면 해석 결과가 비어도 children 으로 폴백하지 않습니다.
*
* React 는 `text !== undefined` 인 순간 children 경로로 내려가지 않는다
* (DynamicRenderer.tsx:2591,2639). 폴백하면 봇 화면에만 없는 내용이 나타난다.
*/
public function test_empty_node_text_does_not_fall_back_to_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '{{missing.path}}',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식폴백']],
],
], ['other' => 'x']);
$this->assertStringNotContainsString('자식폴백', $html);
}
/**
* `?? '$t:key'` 폴백으로 나온 번역 토큰이 text 에서 해석됩니다.
*
* bool/null 판정 때문에 text 해석 경로를 바꾸더라도, 파이프·인라인 `$t:` 후처리를
* 건너뛰면 안 된다 — 건너뛰면 봇 화면에 `$t:home.welcome_description` 이 그대로 남는다.
*/
public function test_translation_token_in_fallback_literal_is_resolved_in_node_text(): void
{
$this->evaluator->setTranslations(['home.welcome_description' => '환영합니다']);
$html = $this->render([
[
'type' => 'basic',
'name' => 'P',
'text' => "{{settings?.site_description ?? '\$t:home.welcome_description'}}",
],
], ['settings' => []]);
$this->assertStringContainsString('환영합니다', $html);
$this->assertStringNotContainsString('$t:', $html);
}
/**
* bool / null 로 평가되는 text 는 렌더되지 않습니다 (JSX 시맨틱).
*
* React 는 단일 바인딩 text 를 raw 값으로 받아 그대로 자식에 넘기므로 {false} 가
* 화면에 나오지 않는다. 문자열화하면 봇 화면에만 'false' 라는 글자가 노출된다.
*/
public function test_boolean_and_null_node_text_are_not_rendered(): void
{
$context = ['flags' => ['on' => true, 'off' => false, 'nothing' => null]];
foreach (['{{flags.off}}' => 'false', '{{flags.on}}' => 'true'] as $expression => $forbidden) {
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => $expression],
], $context);
$this->assertStringNotContainsString($forbidden, $html, "text={$expression} 가 문자열로 노출되었습니다.");
}
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => '{{flags.nothing}}'],
], $context);
$this->assertStringNotContainsString('null', $html);
}
/**
* 알 수 없는 conditions 형식은 렌더링됩니다 (React evaluateRenderCondition 동형).
*
* 여기서 숨기면 봇 화면에서만 노드가 사라진다 — #86 과 동일한 실패 모드.
*/
public function test_unknown_conditions_format_still_renders(): void
{
$cases = [
'빈 배열' => [],
'문자열 리스트' => ['{{flag}}'],
'if/then 없는 객체 리스트' => [['foo' => 1]],
'and/or 없는 객체' => ['foo' => 1],
];
foreach ($cases as $label => $conditions) {
$html = $this->render([
[
'type' => 'basic',
'name' => 'Span',
'conditions' => $conditions,
'text' => '렌더됨',
],
], ['flag' => true]);
$this->assertStringContainsString('렌더됨', $html, "conditions={$label} 이 숨겨졌습니다.");
}
}
/**
* 데스크톱에 매칭되는 responsive 키가 여러 개면 커스텀 범위가 프리셋을 이깁니다.
*
* React ResponsiveManager::getMatchingKey() 와 동일한 우선순위. 매칭 키를 전부
* 순차 병합하면 JSON 키 선언 순서에 따라 봇 화면 출력이 달라진다.
*/
public function test_responsive_custom_range_wins_over_preset(): void
{
$node = [
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'desktop' => ['text' => '프리셋'],
'1024-' => ['text' => '커스텀'],
],
];
$this->assertStringContainsString('커스텀', $this->render([$node]));
// 선언 순서를 뒤집어도 결과가 같아야 한다
$node['responsive'] = [
'1024-' => ['text' => '커스텀'],
'desktop' => ['text' => '프리셋'],
];
$html = $this->render([$node]);
$this->assertStringContainsString('커스텀', $html);
$this->assertStringNotContainsString('프리셋', $html);
}
/**
* 커스텀 범위끼리는 좁은 범위가 넓은 범위를 이깁니다.
*/
public function test_responsive_narrower_range_wins(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1024-' => ['text' => '넓은범위'],
'1024-1279' => ['text' => '좁은범위'],
],
],
]);
$this->assertStringContainsString('좁은범위', $html);
$this->assertStringNotContainsString('넓은범위', $html);
}
/**
* min > max 인 잘못된 범위 키는 무시됩니다 (React parseRange 와 동일).
*/
public function test_responsive_invalid_range_key_is_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1500-1000' => ['text' => '잘못된범위'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('잘못된범위', $html);
}
/**
* responsive.desktop 의 children 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_children_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '기본자식']],
'responsive' => [
'desktop' => [
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '데스크톱자식']],
],
],
],
]);
$this->assertStringContainsString('데스크톱자식', $html);
$this->assertStringNotContainsString('기본자식', $html);
}
/**
* responsive.desktop 의 iteration 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_iteration_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'responsive' => [
'desktop' => [
'iteration' => ['source' => '{{items}}', 'item_var' => 'row'],
],
],
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '{{row.label}}']],
],
], ['items' => [['label' => 'A'], ['label' => 'B']]]);
$this->assertSame(2, substr_count($html, '<span>'));
}
/**
* 데스크톱 폭을 포함하는 숫자 범위 responsive 키가 적용됩니다.
*/
public function test_responsive_numeric_range_matching_desktop_is_applied(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => ['1024-' => ['text' => '넓은화면']],
],
]);
$this->assertStringContainsString('넓은화면', $html);
}
/**
* 데스크톱 폭 밖의 responsive 키(모바일/좁은 범위)는 무시됩니다.
*/
public function test_responsive_non_desktop_breakpoints_are_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'mobile' => ['text' => '모바일'],
'portable' => ['text' => '포터블'],
'0-639' => ['text' => '좁은화면'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('모바일', $html);
$this->assertStringNotContainsString('포터블', $html);
$this->assertStringNotContainsString('좁은화면', $html);
}
/**
* if 결과 문자열 "null" / "undefined" 는 거짓으로 판정됩니다 (React와 동일).
*/
public function test_if_treats_null_and_undefined_strings_as_falsy(): void
{
foreach (['null', 'undefined', 'NULL', ' false ', '0'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertSame('', $html, "if 값 '{$value}' 는 거짓이어야 합니다.");
}
}
/**
* if 결과가 참이면 정상 렌더링됩니다 (거짓 목록 확장의 회귀 방지).
*/
public function test_if_truthy_values_still_render(): void
{
foreach ([true, 'yes', '1', 'nullable'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertStringContainsString('표시', $html);
}
}
// =========================================================================
// 헬퍼 메서드 — config 기반 설정 반환
// =========================================================================
@@ -4276,4 +4788,134 @@ class ComponentHtmlMapperTest extends TestCase
$this->assertStringContainsString('base-class default-class', $html);
}
/**
* Select 의 options 는 option 목록으로 렌더링됩니다.
*
* 일반 화면은 option 라벨을 그리는데 봇 화면에는 선택값(내부 식별자)이 글자로 나가고
* 있었다 (`<select>created_at_desc</select>`).
*/
public function test_select_renders_option_list_from_options_prop(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => [
'className' => 'w-40',
'value' => 'created_at_desc',
'options' => '{{sortOptions ?? []}}',
],
]];
$context = ['sortOptions' => [
['value' => 'created_at_desc', 'label' => '최신순'],
['value' => 'rating_desc', 'label' => '평점 높은순'],
]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="created_at_desc">최신순</option>', $html);
$this->assertStringContainsString('<option value="rating_desc">평점 높은순</option>', $html);
$this->assertStringNotContainsString('>created_at_desc<', $html);
}
/**
* options 가 없는 Select 는 선택값을 글자로 노출하지 않습니다.
*/
public function test_select_without_options_does_not_leak_value_as_text(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'created_at_desc'],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertSame('<select></select>', $html);
}
/**
* children 으로 정의된 Option 은 option 태그로 보존됩니다.
*/
public function test_select_children_options_are_preserved(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'a'],
'children' => [
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'a'], 'text' => '가나다'],
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'b'], 'text' => '라마바'],
],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('<option>가나다</option>', $html);
$this->assertStringContainsString('<option>라마바</option>', $html);
}
/**
* Input/Textarea 도 선택값을 글자로 노출하지 않습니다.
*/
public function test_form_controls_do_not_render_value_prop_as_text(): void
{
$components = [
['type' => 'basic', 'name' => 'Input', 'props' => ['value' => 'internal_code']],
['type' => 'basic', 'name' => 'Textarea', 'props' => ['value' => 'internal_code']],
];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringNotContainsString('internal_code', $html);
}
/**
* 폼 제어가 아닌 태그는 종전대로 value prop 을 텍스트로 승계합니다 (기존 동작 보존).
*/
public function test_non_form_tag_still_uses_value_prop_as_text(): void
{
$components = [['type' => 'basic', 'name' => 'Span', 'props' => ['value' => '표시할 값']]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('>표시할 값<', $html);
}
/**
* iterate 모드에서 item_attrs 와 item_content 는 함께 적용됩니다.
*/
public function test_iterate_mode_applies_attrs_and_content_together(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['options' => '{{items ?? []}}'],
]];
$context = ['items' => [['value' => 'v1', 'label' => '라벨1']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="v1">라벨1</option>', $html);
}
/**
* 속성만 선언된 iterate 모드(image_gallery)는 종전 동작을 유지합니다.
*/
public function test_iterate_mode_attrs_only_keeps_previous_behaviour(): void
{
$components = [[
'type' => 'composite',
'name' => 'ProductImageViewer',
'props' => ['images' => '{{images ?? []}}'],
]];
$context = ['images' => [['url' => 'https://cdn.test/a.png', 'alt' => '이미지']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<img src="https://cdn.test/a.png" alt="이미지">', $html);
}
}
+116
View File
@@ -462,4 +462,120 @@ class DataSourceResolverTest extends TestCase
&& 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'));
}
}
+155 -13
View File
@@ -350,11 +350,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* || 연산자: 우측이 truthy이면 true를 반환합니다.
* || 연산자: 좌측이 falsy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_or_right_truthy(): void
{
@@ -362,11 +362,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* || 연산자: 양쪽 모두 falsy이면 false를 반환합니다.
* || 연산자: 양쪽 모두 falsy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_or_both_falsy(): void
{
@@ -374,11 +374,23 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('false', $result);
$this->assertSame('0', $result);
}
/**
* && 연산자: 양쪽 모두 truthy이면 true를 반환합니다.
* || 연산자: 값 폴백 관용구가 값을 그대로 반환합니다.
*
* 레이아웃이 `{{query?.q || ''}}` 를 값 폴백으로 사용하므로 boolean이 아니라
* 피연산자 값이어야 합니다 (프론트엔드 JS 평가와 동일).
*/
public function test_logical_or_value_fallback_idiom(): void
{
$this->assertSame('test', $this->evaluator->evaluate("{{query?.q || ''}}", ['query' => ['q' => 'test']]));
$this->assertSame('기본값', $this->evaluator->evaluate("{{query?.q || '기본값'}}", ['query' => []]));
}
/**
* && 연산자: 좌측이 truthy이면 우측 피연산자 값을 반환합니다.
*/
public function test_logical_and_both_truthy(): void
{
@@ -386,11 +398,11 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a && b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('1', $result);
}
/**
* && 연산자: 한쪽이 falsy이면 false를 반환합니다.
* && 연산자: 한쪽이 falsy이면 falsy 피연산자 값을 반환합니다.
*/
public function test_logical_and_one_falsy(): void
{
@@ -398,7 +410,7 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a && b}}', $context);
$this->assertSame('false', $result);
$this->assertSame('0', $result);
}
/**
@@ -568,6 +580,52 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('(0 )', $result);
}
/**
* $t:defer: 접두사는 $t: 와 동일한 키로 해석됩니다.
*
* 프론트엔드의 지연 해석 표기를 SEO에서도 인식해야 합니다 — 미인식 시 키가
* `defer:xxx` 가 되어 사전 조회에 실패하고 빈 문자열이 출력됩니다.
*/
public function test_defer_translation_prefix_resolves_same_key(): void
{
$this->evaluator->setTranslations([
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
]);
$result = $this->evaluator->evaluate('$t:defer:shop.product.reviews_count', []);
$this->assertSame('개 리뷰', $result);
}
/**
* $t:defer: 접두사가 파라미터와 함께 사용되어도 해석됩니다.
*/
public function test_defer_translation_prefix_with_params(): void
{
$this->evaluator->setTranslations([
'shop' => ['order' => ['total' => '총 {{amount}}원']],
]);
$result = $this->evaluator->evaluate('$t:defer:shop.order.total|amount=1000', []);
$this->assertSame('총 1000원', $result);
}
/**
* 텍스트 중간의 $t:defer: 토큰도 해석되며 raw 문자열이 남지 않습니다.
*/
public function test_inline_defer_translation_token(): void
{
$this->evaluator->setTranslations([
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
]);
$result = $this->evaluator->evaluate('(0 $t:defer:shop.product.reviews_count)', []);
$this->assertSame('(0 개 리뷰)', $result);
$this->assertStringNotContainsString('defer', $result);
}
// ==========================================
// 후처리: 고립 연산자 정리
// ==========================================
@@ -2047,6 +2105,31 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('prefix hello suffix', $result);
}
/**
* evaluateRaw: 바인딩이 둘 이상이면 보간된 문자열을 반환합니다.
*
* 회귀: 양끝 앵커 정규식이 `{{a}} - {{b}}` 도 단일 바인딩으로 오인해
* 내부를 `a}} - {{b` 로 해석하던 문제.
*/
public function test_evaluate_raw_multiple_bindings_returns_interpolated_string(): void
{
$context = ['a' => '제목', 'b' => '작성자'];
$result = $this->evaluator->evaluateRaw('{{a}} - {{b}}', $context);
$this->assertSame('제목 - 작성자', $result);
}
/**
* evaluateRaw: 중첩 객체 리터럴은 내부에 `}}` 가 있어도 단일 바인딩으로 유지됩니다.
*/
public function test_evaluate_raw_nested_object_literal_stays_single_binding(): void
{
$result = $this->evaluator->evaluateRaw("{{ {outer: {inner: 'value'}} }}", []);
$this->assertSame(['outer' => ['inner' => 'value']], $result);
}
/**
* evaluateRaw: 미존재 경로는 null을 반환합니다.
*/
@@ -2135,7 +2218,7 @@ class ExpressionEvaluatorTest extends TestCase
{
$context = ['val' => 'test'];
$result = $this->evaluator->evaluate("{{[].includes(val)}}", $context);
$result = $this->evaluator->evaluate('{{[].includes(val)}}', $context);
$this->assertSame('false', $result);
}
@@ -2146,7 +2229,7 @@ class ExpressionEvaluatorTest extends TestCase
{
$context = ['status' => ['code' => 200]];
$result = $this->evaluator->evaluate("{{[200, 201, 204].includes(status?.code)}}", $context);
$result = $this->evaluator->evaluate('{{[200, 201, 204].includes(status?.code)}}', $context);
$this->assertSame('true', $result);
}
@@ -2823,7 +2906,7 @@ class ExpressionEvaluatorTest extends TestCase
public function test_object_literal_with_boolean_null(): void
{
$context = [];
$result = $this->evaluator->evaluateRaw("{{ {enabled: true, disabled: false, empty: null} }}", $context);
$result = $this->evaluator->evaluateRaw('{{ {enabled: true, disabled: false, empty: null} }}', $context);
$this->assertIsArray($result);
$this->assertTrue($result['enabled']);
@@ -3252,7 +3335,7 @@ class ExpressionEvaluatorTest extends TestCase
$result = $this->evaluator->evaluate('{{a || b}}', $context);
$this->assertSame('true', $result);
$this->assertSame('hello', $result);
}
/**
@@ -3339,4 +3422,63 @@ class ExpressionEvaluatorTest extends TestCase
$this->assertSame('가격: 15,000원', $result);
}
/**
* 비교식의 원본 값은 참/거짓입니다.
*
* 종전에는 null 을 돌려줘서, 확장 주입 props 의 `?? 기본값` 이 항상 적용되며
* 레이아웃이 선언한 판정(예: `isHtml`)이 통째로 뒤집혔습니다.
*/
public function test_raw_comparison_returns_boolean(): void
{
$context = ['post' => ['content_mode' => 'text']];
$this->assertFalse($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'html'}}", $context));
$this->assertTrue($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'text'}}", $context));
}
/**
* 부정·논리곱 표현식의 원본 값도 참/거짓입니다.
*/
public function test_raw_logical_expression_returns_boolean(): void
{
$context = ['reply' => ['is_secret' => false, 'status' => 'published']];
$this->assertTrue($this->evaluator->evaluateRaw('{{!reply.is_secret}}', $context));
$this->assertTrue($this->evaluator->evaluateRaw("{{reply.status === 'published' && !reply.is_secret}}", $context));
$this->assertFalse($this->evaluator->evaluateRaw("{{reply.status === 'blinded' && !reply.is_secret}}", $context));
}
/**
* `||` 값 폴백 관용구는 참/거짓이 아니라 피연산자 값을 유지합니다.
*/
public function test_raw_or_expression_keeps_operand_value(): void
{
$context = ['query' => ['q' => '']];
$this->assertSame('셔츠', $this->evaluator->evaluateRaw("{{query.q || '셔츠'}}", $context));
}
/**
* 삼항·화살표 함수는 비교식으로 오인되지 않습니다 (기존 동작 보존).
*/
public function test_raw_ternary_and_arrow_function_are_not_treated_as_boolean(): void
{
$context = ['_local' => ['expanded' => true], 'items' => [1, 2, 3]];
$this->assertSame('open', $this->evaluator->evaluateRaw("{{_local.expanded ? 'open' : 'closed'}}", $context));
$this->assertNotSame(true, $this->evaluator->evaluateRaw('{{items.filter(i => i > 1)}}', $context));
}
/**
* 경로·배열 해석은 종전대로 원본 값을 반환합니다 (회귀 방지).
*/
public function test_raw_path_resolution_is_unchanged(): void
{
$context = ['product' => ['tags' => ['a', 'b'], 'stock' => 0, 'name' => '상품']];
$this->assertSame(['a', 'b'], $this->evaluator->evaluateRaw('{{product.tags}}', $context));
$this->assertSame(0, $this->evaluator->evaluateRaw('{{product.stock}}', $context));
$this->assertSame('상품', $this->evaluator->evaluateRaw('{{product.name}}', $context));
}
}
File diff suppressed because it is too large. Load diff
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\HtmlSanitizer;
use Tests\TestCase;
/**
* 봇 화면 HTML 정화기 검증.
*
* 배경: 봇 화면은 사용자 작성 본문(게시글·답변글·페이지·상품 설명)을 그대로 삽입하고
* 있었고, 주소에 봇 파라미터를 붙이면 저장된 `<script>` 가 실제로 실행됐다. 일반 화면은
* DOMPurify 로 정화한 결과를 그리므로 봇 화면도 같은 강도로 정화해야 한다.
*
* 차단 목록의 근거는 `HtmlContent.tsx` 의 DOMPurify 설정이다.
*
* @see HtmlSanitizer
*/
class HtmlSanitizerTest extends TestCase
{
private HtmlSanitizer $sanitizer;
/**
* 테스트 초기화 — 정화기를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->sanitizer = new HtmlSanitizer;
}
/**
* script 태그는 내용까지 제거된다.
*
* @effects active_content_is_removed, html_mode_content_sanitized
*/
public function test_script_tag_is_removed_with_its_content(): void
{
$html = $this->sanitizer->sanitize('<p>본문</p><script>window.__x=1</script>');
$this->assertStringContainsString('<p>본문</p>', $html);
$this->assertStringNotContainsString('<script', $html);
$this->assertStringNotContainsString('window.__x', $html);
}
/**
* 이벤트 핸들러 속성은 태그를 남기고 속성만 제거된다.
*
* @effects event_handler_attributes_are_stripped
*/
public function test_event_handler_attributes_are_stripped(): void
{
$html = $this->sanitizer->sanitize('<img src="a.png" onerror="window.__x=1" alt="설명">');
$this->assertStringContainsString('src="a.png"', $html);
$this->assertStringContainsString('alt="설명"', $html);
$this->assertStringNotContainsString('onerror', $html);
}
/**
* 목록에 없는 이벤트 핸들러(on* 전량)도 제거된다.
*
* @effects event_handler_attributes_are_stripped
*/
public function test_unlisted_event_handlers_are_stripped(): void
{
$html = $this->sanitizer->sanitize('<div onanimationstart="window.__x=1" onpointerover="y()">내용</div>');
$this->assertStringContainsString('내용', $html);
$this->assertStringNotContainsString('onanimationstart', $html);
$this->assertStringNotContainsString('onpointerover', $html);
}
/**
* 외부 콘텐츠 삽입 태그는 제거된다.
*
* @effects active_content_is_removed
*/
public function test_embedding_tags_are_removed(): void
{
$html = $this->sanitizer->sanitize(
'<iframe src="https://e.test"></iframe><object data="x"></object><embed src="y">본문'
);
$this->assertStringNotContainsString('<iframe', $html);
$this->assertStringNotContainsString('<object', $html);
$this->assertStringNotContainsString('<embed', $html);
$this->assertStringContainsString('본문', $html);
}
/**
* 폼 요소는 벗기되 내부 텍스트는 남긴다 (피싱 방지 + 본문 보존).
*
* @effects active_content_is_removed, safe_markup_and_text_are_preserved
*/
public function test_form_elements_are_unwrapped_but_text_is_kept(): void
{
$html = $this->sanitizer->sanitize('<form action="https://e.test"><p>안내 문구</p></form>');
$this->assertStringNotContainsString('<form', $html);
$this->assertStringContainsString('<p>안내 문구</p>', $html);
}
/**
* javascript: 스킴 링크는 href 만 제거되고 글자는 남는다.
*
* @effects dangerous_url_schemes_are_blocked
*/
public function test_javascript_scheme_href_is_removed(): void
{
$html = $this->sanitizer->sanitize('<a href="javascript:alert(1)">링크</a>');
$this->assertStringNotContainsString('javascript:', $html);
$this->assertStringContainsString('링크', $html);
}
/**
* 제어문자를 끼워 넣은 스킴 우회도 차단된다.
*
* @effects dangerous_url_schemes_are_blocked
*/
public function test_obfuscated_javascript_scheme_is_removed(): void
{
$html = $this->sanitizer->sanitize("<a href=\"java\tscript:alert(1)\">링크</a>");
$this->assertStringNotContainsString('script:', $html);
$this->assertStringContainsString('링크', $html);
}
/**
* 상대 경로·앵커·정상 스킴 링크는 보존된다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_normal_urls_are_preserved(): void
{
$html = $this->sanitizer->sanitize(
'<a href="/board/free/1">내부</a><a href="#anchor">앵커</a><a href="mailto:a@b.test">메일</a>'
);
$this->assertStringContainsString('href="/board/free/1"', $html);
$this->assertStringContainsString('href="#anchor"', $html);
$this->assertStringContainsString('href="mailto:a@b.test"', $html);
}
/**
* 외부 링크에는 rel 이 보강된다 (일반 화면 후처리와 동일).
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_external_link_gets_rel_attribute(): void
{
$html = $this->sanitizer->sanitize('<a href="https://external.test">외부</a>');
$this->assertStringContainsString('rel="noopener noreferrer"', $html);
}
/**
* 이미지 data URL 은 허용하고 그 외 data URL 은 차단한다.
*
* @effects dangerous_url_schemes_are_blocked, safe_markup_and_text_are_preserved
*/
public function test_data_url_is_allowed_for_images_only(): void
{
$allowed = $this->sanitizer->sanitize('<img src="data:image/png;base64,AAAA" alt="a">');
$blocked = $this->sanitizer->sanitize('<img src="data:text/html;base64,AAAA" alt="b">');
$this->assertStringContainsString('data:image/png', $allowed);
$this->assertStringNotContainsString('data:text/html', $blocked);
}
/**
* 일반 서식 태그와 한글 텍스트는 그대로 보존된다.
*
* @effects ordinary_formatting_survives_sanitization, safe_markup_and_text_are_preserved
*/
public function test_formatting_markup_and_multibyte_text_survive(): void
{
$source = '<h3>제목</h3><p><strong>굵게</strong> 그리고 <em>기울임</em></p>'
.'<ul><li>첫째</li><li>둘째</li></ul><blockquote>인용문</blockquote>';
$html = $this->sanitizer->sanitize($source);
foreach (['<h3>제목</h3>', '<strong>굵게</strong>', '<em>기울임</em>', '<li>첫째</li>', '<blockquote>인용문</blockquote>'] as $fragment) {
$this->assertStringContainsString($fragment, $html);
}
}
/**
* 표 마크업(게시글에서 흔함)은 보존된다.
*
* @effects ordinary_formatting_survives_sanitization
*/
public function test_table_markup_is_preserved(): void
{
$html = $this->sanitizer->sanitize('<table><tbody><tr><td>값</td></tr></tbody></table>');
$this->assertStringContainsString('<table>', $html);
$this->assertStringContainsString('<td>값</td>', $html);
}
/**
* 빈 문자열/공백은 빈 문자열을 반환한다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_blank_input_returns_empty_string(): void
{
$this->assertSame('', $this->sanitizer->sanitize(''));
$this->assertSame('', $this->sanitizer->sanitize(" \n "));
}
/**
* 태그가 없는 평문은 그대로 남는다.
*
* @effects safe_markup_and_text_are_preserved
*/
public function test_plain_text_passes_through(): void
{
$html = $this->sanitizer->sanitize('태그 없는 본문입니다.');
$this->assertStringContainsString('태그 없는 본문입니다.', $html);
}
/**
* SVG 는 스크립트 실행 벡터라 통째로 제거된다.
*
* @effects active_content_is_removed
*/
public function test_svg_is_removed(): void
{
$html = $this->sanitizer->sanitize('<svg><script>window.__x=1</script></svg><p>본문</p>');
$this->assertStringNotContainsString('<svg', $html);
$this->assertStringNotContainsString('window.__x', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
/**
* 주석은 제거된다.
*
* @effects active_content_is_removed
*/
public function test_comments_are_removed(): void
{
$html = $this->sanitizer->sanitize('<!-- 숨은 주석 --><p>본문</p>');
$this->assertStringNotContainsString('숨은 주석', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
/**
* 중첩 구조 안쪽의 스크립트도 제거된다.
*
* @effects active_content_is_removed
*/
public function test_nested_script_is_removed(): void
{
$html = $this->sanitizer->sanitize('<div><section><p>본문</p><script>window.__x=1</script></section></div>');
$this->assertStringNotContainsString('<script', $html);
$this->assertStringContainsString('<p>본문</p>', $html);
}
}
+52 -2
View File
@@ -4,6 +4,7 @@ namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use App\Seo\ExpressionEvaluator;
use App\Seo\SeoRenderer;
use Tests\TestCase;
/**
@@ -71,6 +72,55 @@ class RealWorldRenderingTest extends TestCase
return $this->mapper->render($components, $context, $this->evaluator);
}
// ========================================================
// 0. $all_props 해석 회귀 — 단일/다중 바인딩 판정 변경의 영향
// ========================================================
/**
* $all_props 모드에서 다중 바인딩 prop 이 보간된 문자열로 해석됩니다.
*
* 회귀: 단일 바인딩 판정이 `{{a}} - {{b}}` 를 단일로 오인하면 내부가
* `a}} - {{b` 로 해석되어 값이 통째로 비워졌습니다.
*/
public function test_all_props_mode_interpolates_multi_binding_prop(): void
{
$html = $this->render([
[
'type' => 'layout',
'name' => 'Header',
'props' => [
'siteName' => '{{site.name}} - {{site.tagline}}',
],
],
], ['site' => ['name' => '그누보드7', 'tagline' => '오픈소스 CMS']]);
$this->assertStringContainsString('그누보드7 - 오픈소스 CMS', $html);
}
/**
* $all_props 모드에서 단일 바인딩 prop 은 원본 타입(배열)을 유지합니다.
*/
public function test_all_props_mode_preserves_single_binding_array(): void
{
$html = $this->render([
[
'type' => 'layout',
'name' => 'Header',
'props' => [
'siteName' => '{{site.name}}',
'menuItems' => '{{menu}}',
],
],
], [
'site' => ['name' => '그누보드7'],
'menu' => [
['label' => '공지', 'url' => '/board/notice'],
],
]);
$this->assertStringContainsString('그누보드7', $html);
}
// ========================================================
// 1. 태그 매핑 검증 — Icon, Img 등이 올바른 HTML 태그를 사용하는지
// ========================================================
@@ -751,7 +801,7 @@ class RealWorldRenderingTest extends TestCase
$this->assertTrue($hasFontAwesome, 'Font Awesome CSS가 seo-config.json에 포함되어야 합니다');
// SeoRenderer.getTemplateCssUrls()가 템플릿 CSS를 자동 해석하는지 확인
$renderer = app(\App\Seo\SeoRenderer::class);
$renderer = app(SeoRenderer::class);
$method = new \ReflectionMethod($renderer, 'getTemplateCssUrls');
$method->setAccessible(true);
@@ -764,7 +814,7 @@ class RealWorldRenderingTest extends TestCase
// dist/css/components.css → /api/templates/assets/{id}/css/components.css 변환 검증
foreach ($cssPaths as $cssPath) {
$servePath = preg_replace('#^dist/#', '', $cssPath);
$expectedUrl = '/api/templates/assets/sirsoft-basic/' . $servePath;
$expectedUrl = '/api/templates/assets/sirsoft-basic/'.$servePath;
$this->assertStringContainsString('components.css', $expectedUrl,
'템플릿 CSS URL이 올바르게 변환되어야 합니다');
}
@@ -0,0 +1,196 @@
<?php
namespace Tests\Unit\Seo;
use Tests\TestCase;
/**
* 봇 대상 레이아웃이 화면에 쓰는 데이터소스를 빠짐없이 선언했는지 감시합니다.
*
* 배경:
* 봇 렌더링은 `meta.seo.data_sources` 에 적힌 id 만 미리 조회한다. 화면이 쓰는
* 데이터소스가 목록에서 빠지면 그 값은 늘 비어 있고, 그 값을 조건으로 삼는 블록이
* 통째로 사라진다. 실제로 `users/posts` 레이아웃이 목록 전체를 `userPosts` 로
* 감싸 두고 그 id 를 선언하지 않아, 봇에게는 머리말과 꼬리말만 있는 빈 화면이
* 나갔다.
*
* 봇에 노출할 필요가 없어 일부러 뺀 데이터소스는 아래 EXEMPT 목록에 사유와 함께
* 등록한다. 목록에 없는 미선언 사용처가 나타나면 실패한다.
*/
class SeoLayoutDataSourceDeclarationTest extends TestCase
{
/**
* 검사 대상 템플릿의 레이아웃 디렉토리 (프로젝트 루트 기준 상대 경로).
*/
private const LAYOUT_ROOT = 'templates/_bundled/sirsoft-basic/layouts';
/**
* 의도적으로 봇 조회에서 제외한 데이터소스 — `레이아웃::데이터소스 id => 사유`.
*/
private const EXEMPT = [
'shop/index::recentProducts' => '브라우저에 저장된 열람 이력이 필요해 봇에게는 늘 빈 값',
'shop/show::productDownloadableCoupons' => '로그인 사용자 전용 쿠폰이라 봇에게 의미 없음',
];
/**
* 봇 대상 레이아웃은 화면이 참조하는 데이터소스를 모두 선언해야 합니다.
*
* @effects every_used_data_source_is_declared
*/
public function test_seo_layouts_declare_every_data_source_they_use(): void
{
$root = base_path(self::LAYOUT_ROOT);
$this->assertDirectoryExists($root);
$violations = [];
$checked = 0;
foreach ($this->layoutFiles($root) as $file) {
$layout = json_decode((string) file_get_contents($file), true);
if (! is_array($layout)) {
continue;
}
$seo = $layout['meta']['seo'] ?? null;
if (! is_array($seo) || ($seo['enabled'] ?? false) !== true) {
continue;
}
$checked++;
$layoutName = $this->layoutName($root, $file);
$declared = $seo['data_sources'] ?? [];
$body = $this->layoutBodyText($root, $file);
foreach ($layout['data_sources'] ?? [] as $dataSource) {
$id = $dataSource['id'] ?? null;
if (! is_string($id) || $id === '' || in_array($id, $declared, true)) {
continue;
}
if (isset(self::EXEMPT[$layoutName.'::'.$id])) {
continue;
}
if ($this->isReferenced($id, $body)) {
$violations[] = "{$layoutName}: '{$id}' 를 화면에서 참조하지만 meta.seo.data_sources 에 없음";
}
}
}
$this->assertGreaterThan(0, $checked, '봇 대상 레이아웃을 하나도 찾지 못했습니다 — 스캔 경로를 확인하세요.');
$this->assertSame([], $violations, implode(PHP_EOL, $violations));
}
/**
* 면제 목록에 죽은 항목이 남아 있지 않은지 확인합니다.
*
* @effects intentional_exclusions_are_documented
*/
public function test_exempt_entries_still_exist(): void
{
$root = base_path(self::LAYOUT_ROOT);
foreach (array_keys(self::EXEMPT) as $entry) {
[$layoutName, $id] = explode('::', $entry, 2);
$path = $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $layoutName).'.json';
$this->assertFileExists($path, "면제 항목의 레이아웃이 없습니다: {$entry}");
$layout = json_decode((string) file_get_contents($path), true);
$ids = array_column($layout['data_sources'] ?? [], 'id');
$this->assertContains($id, $ids, "면제 항목의 데이터소스가 없습니다: {$entry}");
}
}
/**
* 레이아웃 JSON 파일 목록을 반환합니다 (admin 하위 제외 — 봇 대상 아님).
*
* @param string $root 레이아웃 루트 절대 경로
* @return array<int, string> 파일 절대 경로 목록
*/
private function layoutFiles(string $root): array
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
$files = [];
foreach ($iterator as $file) {
if (! $file->isFile() || $file->getExtension() !== 'json') {
continue;
}
$path = $file->getPathname();
if (str_contains(str_replace('\\', '/', $path), '/admin/')) {
continue;
}
$files[] = $path;
}
sort($files);
return $files;
}
/**
* 레이아웃 파일 경로를 레이아웃명으로 변환합니다.
*
* @param string $root 레이아웃 루트 절대 경로
* @param string $file 레이아웃 파일 절대 경로
* @return string 레이아웃명 (예: users/posts)
*/
private function layoutName(string $root, string $file): string
{
$relative = ltrim(str_replace('\\', '/', substr($file, strlen($root))), '/');
return preg_replace('/\.json$/', '', $relative) ?? $relative;
}
/**
* 레이아웃 본문 + 참조 partial 본문을 하나의 문자열로 모읍니다.
*
* 화면 구성이 partial 로 쪼개져 있어 자기 파일만 봐서는 참조 여부를 알 수 없습니다.
*
* @param string $root 레이아웃 루트 절대 경로
* @param string $file 레이아웃 파일 절대 경로
* @param array<string, bool> $visited 순환 방지용 방문 기록
* @return string 합쳐진 JSON 문자열
*/
private function layoutBodyText(string $root, string $file, array &$visited = []): string
{
$normalized = str_replace('\\', '/', $file);
if (isset($visited[$normalized]) || ! is_file($file)) {
return '';
}
$visited[$normalized] = true;
$raw = (string) file_get_contents($file);
$text = $raw;
preg_match_all('/"partial"\s*:\s*"([^"]+)"/', $raw, $matches);
foreach ($matches[1] ?? [] as $partial) {
$text .= $this->layoutBodyText($root, $root.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $partial), $visited);
}
return $text;
}
/**
* 데이터소스 id 가 표현식/조건에서 참조되는지 판정합니다.
*
* `data_sources` 선언부의 `"id": "..."` 는 참조가 아니므로 제외하고, 표현식 안에서
* 식별자로 등장하는 경우만 참조로 봅니다.
*
* @param string $id 데이터소스 id
* @param string $body 레이아웃 + partial 본문
* @return bool 참조하면 true
*/
private function isReferenced(string $id, string $body): bool
{
$withoutDeclarations = preg_replace('/"id"\s*:\s*"'.preg_quote($id, '/').'"/', '', $body) ?? $body;
return preg_match('/[{(\s|!?,]'.preg_quote($id, '/').'[?.\s)]/', $withoutDeclarations) === 1;
}
}
+382
View File
@@ -0,0 +1,382 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use Tests\TestCase;
/**
* SEO(봇) 렌더러가 해석하지 못하는 노드 키가 봇 대상 화면에 유입되는지 감시합니다.
*
* 배경 (이슈 #86):
* 같은 레이아웃 JSON 을 React 렌더러와 SEO PHP 렌더러가 각각 그린다. React 에만 새 노드
* 키 처리를 추가하면 봇 화면에서는 그 부분이 조용히 사라지는데, 실제로 확장 주입 props
* (`extensionPointProps`) 가 그렇게 누락되어 게시글·댓글·페이지·상품 설명 본문이
* 통째로 빈 요소로 나갔다.
*
* 이 테스트는 봇 대상 자산에서 사용되는 노드 최상위 키를 모두 모아, 아래 두 목록의
* 합집합에 속하는지 확인한다. 새 키가 등장하면 실패하므로 "SEO 렌더러가 이 키를
* 알아야 하는가" 를 반드시 판단하게 된다.
*
* 목록을 갱신할 때는 docs/backend/seo-system.md 의 지원 노드 키 표도 함께 갱신한다.
*
* @see ComponentHtmlMapper
*/
class SeoNodeKeyParityTest extends TestCase
{
/**
* SEO 렌더러가 해석하는 노드 키.
*/
private const SUPPORTED_KEYS = [
'name',
'type',
'props',
'children',
'text',
'if',
'condition',
'conditions',
'iteration',
'classMap',
'responsive',
'actions',
'extensionPointProps',
// type: "iterator" 형태의 반복 정의
'data',
'itemName',
'indexName',
];
/**
* SEO 렌더러가 의도적으로 무시하는 노드 키.
*
* 인터랙션·편집기·빌드 메타 전용이라 봇 화면 콘텐츠에 영향을 주지 않는다.
*/
private const INTENTIONALLY_IGNORED_KEYS = [
'id',
'key',
'comment',
'_comment',
// 설치 시점에 인라인 확장되므로 SEO 렌더 시에는 남지 않는다
'partial',
// 레이아웃 병합 단계에서 처리된다
'slot',
'slotOrder',
// extension_point 호스트의 기본 children — LayoutExtensionService 가 렌더러 진입 전에
// 주입 컴포넌트로 교체하거나(mode=replace) 그대로 펼쳐 두므로 SEO 렌더러에는 남지 않는다
'default',
// 인터랙션 전용
'lifecycle',
'onComponentEvent',
'dataKey',
'trackChanges',
'debounce',
'blur_until_loaded',
'sortable',
'itemTemplate',
'expandChildren',
'component_layout',
'isolatedState',
'isolatedScopeId',
'extensionPointCallbacks',
// extension_point 호스트가 선언하는 콜백 원본 — LayoutExtensionService 가 이를 읽어
// 주입 컴포넌트에 extensionPointCallbacks 로 부착한다. SEO 에는 액션 디스패처가 없다
'callbacks',
// 노드 최상위에 잘못 놓인 컴포넌트 prop (props 안에 있어야 정상).
// React 도 props 객체만 읽으므로(DynamicRenderer.tsx:2235) 양쪽 모두 무시 — 패리티 유지.
// 렌더링에 관여하지 않는 잔여 키이며, props 로 옮기면 아이콘 크기가 실제로 바뀌므로
// SEO 패리티와 분리해 판단할 사항이다
'size',
// 표현 전용 / 편집기 메타
'style',
'__source',
'_fromBase',
];
/**
* 봇 대상 자산에서 사용되는 노드 키가 모두 판정된 목록 안에 있습니다.
*
* @effects unclassified_node_key_is_detected
*/
public function test_all_node_keys_in_bot_facing_assets_are_classified(): void
{
$known = array_merge(self::SUPPORTED_KEYS, self::INTENTIONALLY_IGNORED_KEYS);
$unknown = [];
foreach ($this->botFacingLayoutFiles() as $file) {
$decoded = json_decode((string) file_get_contents($file), true);
if (! is_array($decoded)) {
continue;
}
foreach ($this->collectNodeKeys($decoded) as $key) {
if (! in_array($key, $known, true)) {
$unknown[$key][] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file);
}
}
}
$this->assertSame([], $unknown, sprintf(
"SEO 렌더러 판정 목록에 없는 노드 키가 봇 대상 화면에 있습니다: %s\n".
'→ SEO 렌더러가 이 키를 해석해야 하는지 판단한 뒤, SeoNodeKeyParityTest 의 목록과 '.
'docs/backend/seo-system.md 의 지원 노드 키 표를 함께 갱신하세요.',
json_encode(array_map(fn ($files) => array_slice(array_unique($files), 0, 3), $unknown), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
));
}
/**
* 문서 루트가 곧 노드인 partial 파일도 실제로 스캔됩니다.
*
* 수집 진입점이 `components`/`slots` 뿐이면 봇 대상 자산의 대부분(partial)이 통째로
* 스킵되어 위 분류 단언이 항상 통과한다 — 이슈 #86 이 터진 `_reply_section.json` 이
* 바로 그 형태이므로, 감시망이 비어 있는 채로 초록이 되는 상황을 여기서 차단한다.
*
* @effects node_rooted_partials_are_scanned
*/
public function test_scan_covers_node_rooted_partial_documents(): void
{
$file = base_path('templates/_bundled/sirsoft-basic/layouts/partials/board/show/_reply_section.json');
$this->assertFileExists($file);
$document = json_decode((string) file_get_contents($file), true);
$this->assertIsArray($document);
$this->assertArrayNotHasKey('components', $document, '전제 변경: 이 파일이 더 이상 노드 루트 형태가 아닙니다.');
$keys = $this->collectNodeKeys($document);
$this->assertNotEmpty($keys, '노드 루트 partial 에서 키가 수집되지 않았습니다 — 수집 진입점 누락.');
$this->assertContains('children', $keys);
$this->assertNotContains('meta', $keys, '문서 메타 키가 노드 키로 잘못 수집되었습니다.');
// 자손까지 내려갔는지 (루트 키만 모으고 끝나면 안 된다)
$this->assertContains('iteration', $keys, '자손 노드까지 재귀하지 않았습니다.');
}
/**
* 스캔 대상 파일 중 노드 루트 partial 이 실제로 다수를 차지하며 모두 수집됩니다.
*
* @effects node_rooted_partials_are_scanned
*/
public function test_node_rooted_partials_are_not_silently_skipped(): void
{
$nodeRooted = 0;
$collected = 0;
foreach ($this->botFacingLayoutFiles() as $file) {
$document = json_decode((string) file_get_contents($file), true);
if (! is_array($document) || isset($document['components']) || isset($document['slots'])) {
continue;
}
if (! isset($document['type']) && ! isset($document['name'])) {
continue;
}
$nodeRooted++;
if ($this->collectNodeKeys($document) !== []) {
$collected++;
}
}
$this->assertGreaterThan(0, $nodeRooted, '노드 루트 partial 탐색 자체가 실패했습니다.');
$this->assertSame($nodeRooted, $collected, sprintf(
'노드 루트 partial %d 개 중 %d 개에서만 키가 수집되었습니다.',
$nodeRooted,
$collected
));
}
/**
* 지원/무시 목록이 서로 겹치지 않습니다 (분류 모호성 차단).
*
* @effects key_classification_is_unambiguous
*/
public function test_supported_and_ignored_key_lists_are_disjoint(): void
{
$this->assertSame(
[],
array_values(array_intersect(self::SUPPORTED_KEYS, self::INTENTIONALLY_IGNORED_KEYS)),
'같은 키가 지원 목록과 무시 목록에 동시에 있습니다.'
);
}
/**
* 봇 렌더링 대상이 되는 레이아웃/확장 자산 파일 목록을 반환합니다.
*
* 관리자 템플릿과 admin 레이아웃은 인증이 필요해 봇이 접근할 수 없으므로 제외합니다.
*
* @return array<int, string> 파일 절대 경로 목록
*/
private function botFacingLayoutFiles(): array
{
$roots = [
base_path('templates/_bundled/sirsoft-basic/layouts'),
base_path('templates/_bundled/sirsoft-basic/extensions'),
base_path('plugins/_bundled/sirsoft-ckeditor5/resources/extensions'),
base_path('plugins/_bundled/sirsoft-daum_postcode/resources/extensions'),
base_path('plugins/_bundled/sirsoft-gdpr/resources/extensions'),
];
$files = [];
foreach ($roots as $root) {
if (! is_dir($root)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
foreach ($iterator as $entry) {
if (! $entry->isFile() || $entry->getExtension() !== 'json') {
continue;
}
$path = $entry->getPathname();
if (str_contains(str_replace('\\', '/', $path), '/admin/')) {
continue;
}
$files[] = $path;
}
}
return $files;
}
/**
* 문서(레이아웃/확장 자산) 최상위에만 나타나는 키.
*
* partial 파일은 문서 루트 자체가 컴포넌트 노드라(§collectNodeKeys) 루트 키를 노드 키로
* 수집하는데, 그중 이 목록의 키는 노드가 아니라 문서 메타이므로 제외한다.
*/
private const DOCUMENT_ONLY_KEYS = [
'meta',
'version',
'extends',
'layout_name',
'data_sources',
'computed',
'permissions',
'init_actions',
'modals',
'states',
'defines',
'error_handling',
'globalHeaders',
'scripts',
'priority',
'extension_point',
'target_layout',
'target_id',
'position',
'mode',
'injections',
];
/**
* 레이아웃/확장 자산에서 컴포넌트 노드의 최상위 키를 수집합니다.
*
* 진입점은 셋이다:
* 1. `components` / `slots.*` — 일반 레이아웃 문서, 확장 자산(`extension_point` 형)
* 2. `injections[].components` — 주입 목록 형 확장 자산
* 3. 위 어느 것도 없고 `type`/`name` 이 있으면 **문서 루트 자체가 컴포넌트 노드** (partial)
*
* 3번을 빠뜨리면 봇 대상 자산의 대부분(partial)이 통째로 감시망 밖에 남는다 — 이슈 #86 이
* 실제로 터진 `partials/board/show/_reply_section.json` 이 정확히 이 형태다.
*
* 각 노드에서 `children` / `default` / `responsive.*.children` / `itemTemplate` 로 재귀한다.
*
* @param array $document 디코드된 레이아웃/확장 자산
* @return array<int, string> 사용된 노드 키 목록 (중복 제거)
*/
private function collectNodeKeys(array $document): array
{
$keys = [];
$hasComponentEntryPoint = false;
foreach ($document['components'] ?? [] as $node) {
$hasComponentEntryPoint = true;
$this->collectFromNode($node, $keys);
}
$slots = $document['slots'] ?? [];
if (is_array($slots)) {
foreach ($slots as $slotNodes) {
if (! is_array($slotNodes)) {
continue;
}
$hasComponentEntryPoint = true;
foreach ($slotNodes as $node) {
$this->collectFromNode($node, $keys);
}
}
}
$injections = $document['injections'] ?? [];
if (is_array($injections)) {
foreach ($injections as $injection) {
foreach ($injection['components'] ?? [] as $node) {
$hasComponentEntryPoint = true;
$this->collectFromNode($node, $keys);
}
}
}
// partial: 문서 루트가 곧 컴포넌트 노드
if (! $hasComponentEntryPoint && (isset($document['type']) || isset($document['name']))) {
// 루트에서만 문서 메타 키를 제외하고, 자손은 일반 노드와 동일하게 수집한다
foreach (array_keys($document) as $key) {
if (! in_array((string) $key, self::DOCUMENT_ONLY_KEYS, true)) {
$keys[] = (string) $key;
}
}
$this->collectDescendantKeys($document, $keys);
}
return array_values(array_unique($keys));
}
/**
* 단일 노드와 그 자손의 최상위 키를 수집합니다.
*
* @param mixed $node 노드
* @param array $keys 수집 결과 (참조)
*/
private function collectFromNode(mixed $node, array &$keys): void
{
if (! is_array($node) || array_is_list($node)) {
return;
}
foreach (array_keys($node) as $key) {
$keys[] = (string) $key;
}
$this->collectDescendantKeys($node, $keys);
}
/**
* 노드의 자손(children / default / responsive.*.children / itemTemplate) 키를 수집합니다.
*
* @param array $node 노드
* @param array $keys 수집 결과 (참조)
*/
private function collectDescendantKeys(array $node, array &$keys): void
{
foreach (['children', 'default'] as $arrayKey) {
foreach ($node[$arrayKey] ?? [] as $child) {
$this->collectFromNode($child, $keys);
}
}
if (isset($node['responsive']) && is_array($node['responsive'])) {
foreach ($node['responsive'] as $override) {
foreach ($override['children'] ?? [] as $child) {
$this->collectFromNode($child, $keys);
}
}
}
if (isset($node['itemTemplate'])) {
$this->collectFromNode($node['itemTemplate'], $keys);
}
}
}
+192 -10
View File
@@ -4,10 +4,12 @@ namespace Tests\Unit\Seo;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Extension\AbstractModule;
use App\Extension\HookManager;
use App\Seo\ComponentHtmlMapper;
use App\Seo\DataSourceResolver;
use App\Seo\ExpressionEvaluator;
use App\Seo\PipeRegistry;
use App\Seo\SeoConfigMerger;
use App\Seo\SeoMetaResolver;
use App\Seo\SeoRenderer;
@@ -16,6 +18,7 @@ use App\Services\LayoutService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Services\TemplateService;
use App\Support\AssetUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Mockery;
@@ -86,8 +89,10 @@ class SeoRendererTest extends TestCase
}
if (is_array($value)) {
$locale = app()->getLocale();
return (string) ($value[$locale] ?? reset($value) ?? '');
}
return (string) ($value ?? '');
})
->byDefault();
@@ -105,7 +110,7 @@ class SeoRendererTest extends TestCase
// 기본 evaluator mock (setTranslations, getPipeRegistry 허용)
$this->evaluator->shouldReceive('setTranslations')
->byDefault();
$pipeRegistry = Mockery::mock(\App\Seo\PipeRegistry::class);
$pipeRegistry = Mockery::mock(PipeRegistry::class);
$pipeRegistry->shouldReceive('setLocale')->byDefault();
$this->evaluator->shouldReceive('getPipeRegistry')
->andReturn($pipeRegistry)
@@ -542,9 +547,17 @@ class SeoRendererTest extends TestCase
->andReturn($mergedLayout);
// seo.data_sources에 'product'만 지정 → resolve에 전달되는 seoDataSourceIds 확인
// 6번째 인자는 엔드포인트 표현식 평가용 사전 컨텍스트 (route/query/_global)
$this->dataSourceResolver
->shouldReceive('resolve')
->with($allDataSources, ['product'], ['id' => '1'], Mockery::any(), Mockery::any())
->with(
$allDataSources,
['product'],
['id' => '1'],
Mockery::any(),
Mockery::any(),
Mockery::on(fn ($preContext) => isset($preContext['_global']) && isset($preContext['route']))
)
->once()
->andReturn(['product' => ['data' => ['name' => '상품']]]);
@@ -753,6 +766,10 @@ class SeoRendererTest extends TestCase
],
], JSON_PRETTY_PRINT));
// 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라
// 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다.
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
try {
$request = Request::create('/test-css');
@@ -800,6 +817,7 @@ class SeoRendererTest extends TestCase
$result = $this->renderer->render($request);
$this->assertNotNull($result);
} finally {
AssetUrl::forceMode(null);
@unlink("{$configDir}/template.json");
@rmdir($configDir);
}
@@ -909,6 +927,9 @@ class SeoRendererTest extends TestCase
?array $structuredData = null,
?array $computed = null,
?array $extensions = null,
?array $state = null,
?array $initLocal = null,
?array $initGlobal = null,
): array {
$seo = [
'enabled' => $seoEnabled,
@@ -948,6 +969,18 @@ class SeoRendererTest extends TestCase
$layout['computed'] = $computed;
}
if ($state !== null) {
$layout['state'] = $state;
}
if ($initLocal !== null) {
$layout['initLocal'] = $initLocal;
}
if ($initGlobal !== null) {
$layout['initGlobal'] = $initGlobal;
}
return $layout;
}
@@ -2332,7 +2365,9 @@ class SeoRendererTest extends TestCase
Mockery::on(function ($queryParams) {
return isset($queryParams['page']) && $queryParams['page'] === '2'
&& isset($queryParams['sort']) && $queryParams['sort'] === 'price_asc';
})
}),
// 엔드포인트 표현식 평가용 사전 컨텍스트에도 동일한 쿼리가 담긴다
Mockery::on(fn ($preContext) => ($preContext['query']['page'] ?? null) === '2')
)
->once()
->andReturn(['products' => ['data' => []]]);
@@ -2553,6 +2588,145 @@ class SeoRendererTest extends TestCase
$this->assertNotNull($result);
}
/**
* 레이아웃 최상위 state 블록이 _local 초기값으로 반영됩니다.
*/
public function test_render_applies_layout_state_block_to_local(): void
{
$this->runInitialStateTest(
layoutArgs: ['state' => ['q' => '', 'view' => 'list']],
assertContext: fn ($context) => ($context['_local']['q'] ?? null) === ''
&& ($context['_local']['view'] ?? null) === 'list',
);
}
/**
* initLocal 블록이 state 블록보다 우선합니다 (state 는 하위 호환 별칭).
*/
public function test_render_prefers_init_local_over_state_block(): void
{
$this->runInitialStateTest(
layoutArgs: [
'state' => ['view' => 'list'],
'initLocal' => ['view' => 'grid'],
],
assertContext: fn ($context) => ($context['_local']['view'] ?? null) === 'grid',
);
}
/**
* 레이아웃 최상위 initGlobal 블록이 _global 초기값으로 반영됩니다.
*/
public function test_render_applies_layout_init_global_block(): void
{
$this->runInitialStateTest(
layoutArgs: ['initGlobal' => ['searchActiveTab' => 'all', 'searchPage' => 1]],
assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'all'
&& ($context['_global']['searchPage'] ?? null) === 1,
);
}
/**
* init_actions의 setState(target: global)이 _global에 반영됩니다.
*/
public function test_render_applies_init_actions_global_set_state_to_global(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initActions' => [
[
'handler' => 'setState',
'params' => ['target' => 'global', 'searchSortBy' => 'relevance'],
],
],
],
assertContext: fn ($context) => ($context['_global']['searchSortBy'] ?? null) === 'relevance',
);
}
/**
* init_actions의 setState(target: global)이 초기 initGlobal 값을 덮어씁니다.
*/
public function test_render_init_actions_global_overrides_init_global_block(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initGlobal' => ['searchActiveTab' => 'all'],
'initActions' => [
[
'handler' => 'setState',
'params' => ['target' => 'global', 'searchActiveTab' => 'products'],
],
],
],
assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'products',
);
}
/**
* target: global 인 setState가 실은 _local 하위 객체는 _local로 병합됩니다.
*/
public function test_render_global_set_state_nested_local_is_merged_into_local(): void
{
$this->runInitialStateTest(
layoutArgs: [
'initActions' => [
[
'handler' => 'setState',
'params' => [
'target' => 'global',
'searchActiveTab' => 'all',
'_local' => ['q' => '검색어'],
],
],
],
],
assertContext: fn ($context) => ($context['_local']['q'] ?? null) === '검색어'
&& ! array_key_exists('_local', $context['_global']),
);
}
/**
* 레이아웃 초기 상태(state/initLocal/initGlobal/init_actions) 검증 공통 헬퍼입니다.
*
* @param array $layoutArgs buildMergedLayout 에 전달할 명명 인자
* @param \Closure $assertContext htmlMapper.render 에 전달된 컨텍스트 검증 클로저
*/
private function runInitialStateTest(array $layoutArgs, \Closure $assertContext): void
{
$request = Request::create('/search');
$this->setupRouteResolver('/search', [
'templateIdentifier' => 'sirsoft-basic',
'layoutName' => 'search/index',
'routeParams' => [],
'moduleIdentifier' => null,
'routeMeta' => [],
]);
$mergedLayout = $this->buildMergedLayout(...array_merge([
'seoEnabled' => true,
'components' => [['component' => 'Div', 'props' => []]],
], $layoutArgs));
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$this->metaResolver->shouldReceive('resolve')
->once()
->andReturn($this->buildMetaResult(title: '검색'));
$this->htmlMapper->shouldReceive('render')
->with(Mockery::type('array'), Mockery::on($assertContext), Mockery::type(ExpressionEvaluator::class))
->once()
->andReturn('');
$viewMock = Mockery::mock(\Illuminate\View\View::class);
$viewMock->shouldReceive('render')->once()->andReturn('<html></html>');
View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock);
$this->assertNotNull($this->renderer->render($request));
}
/**
* init_actions에서 setState가 아닌 핸들러(loadFromLocalStorage, closeModal)는 무시됩니다.
*/
@@ -3330,7 +3504,7 @@ class SeoRendererTest extends TestCase
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
// 모듈 인스턴스 mock
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3411,7 +3585,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3486,7 +3660,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'_common' => [
'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'],
@@ -3550,7 +3724,7 @@ class SeoRendererTest extends TestCase
);
$this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout);
$moduleMock = Mockery::mock(\App\Extension\AbstractModule::class)->makePartial();
$moduleMock = Mockery::mock(AbstractModule::class)->makePartial();
$moduleMock->shouldReceive('seoVariables')->andReturn([
'product' => [
'product_name' => ['source' => 'data', 'required' => true],
@@ -3685,6 +3859,7 @@ class SeoRendererTest extends TestCase
// 청취자: image_width 1200 으로 변경
HookManager::addFilter('core.seo.filter_og_data', function (array $og) {
$og['image_width'] = 1200;
return $og;
});
@@ -3705,7 +3880,10 @@ class SeoRendererTest extends TestCase
$result = $this->renderer->render($request);
$this->assertNotNull($result);
HookManager::resetAll();
// resetAll() 은 부팅 시 등록된 코어/확장 리스너까지 통째로 지운다 — HookManager 가
// static 이라 같은 프로세스의 이후 테스트가 그 영향을 받는다. 이 테스트가 등록한
// 필터만 해제한다 (파일 내 다른 hook 테스트와 동일 패턴).
HookManager::clearFilter('core.seo.filter_og_data');
}
/**
@@ -3738,8 +3916,11 @@ class SeoRendererTest extends TestCase
// 청취자: review 배열 주입
HookManager::addFilter('core.seo.filter_structured_data', function (?array $sd) {
if ($sd === null) return $sd;
if ($sd === null) {
return $sd;
}
$sd['review'] = [['@type' => 'Review', 'reviewBody' => 'Great!']];
return $sd;
});
@@ -3755,6 +3936,7 @@ class SeoRendererTest extends TestCase
$this->renderer->render($request);
HookManager::resetAll();
// resetAll() 대신 이 테스트가 등록한 필터만 해제 (위 og_data 테스트와 동일 사유)
HookManager::clearFilter('core.seo.filter_structured_data');
}
}
@@ -0,0 +1,159 @@
<?php
namespace Tests\Unit\Upgrades;
require_once __DIR__.'/../../../upgrades/data/7.0.6/migrations/02_ClearSeoPageCacheAfterRendererParityFix.php';
use App\Extension\Cache\CoreCacheDriver;
use App\Extension\UpgradeContext;
use App\Upgrades\Data\V7_0_6\Migrations\ClearSeoPageCacheAfterRendererParityFix;
use Tests\TestCase;
/**
* 7.0.6 업그레이드 스텝 — 봇 페이지 캐시 1회 무효화
*
* 7.0.6 이전 렌더러가 빈 본문으로 저장한 캐시가 남아 있으면, 렌더러를 고쳐도
* 캐시 수명 동안 검색 봇이 계속 빈 페이지를 받는다. 업그레이드 시 한 번 비운다.
*/
class ClearSeoPageCacheAfterRendererParityFixTest extends TestCase
{
private ClearSeoPageCacheAfterRendererParityFix $migration;
private CoreCacheDriver $cache;
/**
* 테스트 초기화 - 배열 캐시 스토어와 마이그레이션을 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
config()->set('cache.default', 'array');
$this->cache = new CoreCacheDriver('array');
$this->cache->flush();
$this->migration = new ClearSeoPageCacheAfterRendererParityFix;
}
/**
* 업그레이드 컨텍스트를 만듭니다.
*
* @return UpgradeContext 컨텍스트
*/
private function context(): UpgradeContext
{
return new UpgradeContext('7.0.5', '7.0.6', '7.0.6');
}
/**
* 인덱스에 등록된 캐시 항목과 인덱스 자체가 모두 삭제됩니다.
*
* @effects stale_page_cache_is_cleared_on_upgrade, cache_index_is_cleared_on_upgrade
*/
public function test_clears_indexed_page_cache_entries_and_index(): void
{
$this->cache->put('seo.page.abc', '<html>빈 본문</html>', 3600);
$this->cache->put('seo.page.def', '<html>빈 본문2</html>', 3600);
$this->cache->put('seo.cached_urls', [
['key' => 'seo.page.abc', 'url' => '/board/free/1'],
['key' => 'seo.page.def', 'url' => '/page/about'],
], 3600);
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.abc'));
$this->assertNull($this->cache->get('seo.page.def'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 인덱스가 없으면 아무 것도 하지 않습니다 (no-op).
*
* @effects invalidation_is_idempotent, unrelated_cache_namespaces_are_preserved
*/
public function test_no_index_is_noop(): void
{
$this->cache->put('unrelated.key', 'keep', 3600);
$this->migration->run($this->context());
$this->assertSame('keep', $this->cache->get('unrelated.key'));
}
/**
* 재실행해도 부작용이 없습니다 (멱등).
*
* @effects invalidation_is_idempotent
*/
public function test_is_idempotent(): void
{
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [['key' => 'seo.page.abc', 'url' => '/']], 3600);
$this->migration->run($this->context());
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.abc'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 인덱스 항목이 손상되어 있어도 나머지를 삭제하고 인덱스를 비웁니다.
*
* @effects stale_page_cache_is_cleared_on_upgrade, cache_index_is_cleared_on_upgrade
*/
public function test_malformed_index_entries_are_skipped(): void
{
$this->cache->put('seo.page.ok', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [
'not-an-array',
['url' => '/키없음'],
['key' => '', 'url' => '/빈키'],
['key' => 'seo.page.ok', 'url' => '/정상'],
], 3600);
$this->migration->run($this->context());
$this->assertNull($this->cache->get('seo.page.ok'));
$this->assertNull($this->cache->get('seo.cached_urls'));
}
/**
* 봇 페이지 캐시 외의 캐시는 건드리지 않습니다.
*
* @effects unrelated_cache_namespaces_are_preserved
*/
public function test_unrelated_cache_keys_are_preserved(): void
{
$this->cache->put('extension.middleware_index', ['a'], 3600);
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [['key' => 'seo.page.abc', 'url' => '/']], 3600);
$this->migration->run($this->context());
$this->assertSame(['a'], $this->cache->get('extension.middleware_index'));
}
/**
* 인덱스가 손상되어 다른 네임스페이스 키를 가리켜도 그 키는 지우지 않습니다.
*
* 삭제 대상을 봇 페이지 캐시 프리픽스(`seo.page.`)로 한정해, 오염된 인덱스가
* 무관한 캐시를 함께 날리는 사고를 막습니다.
*
* @effects unrelated_cache_namespaces_are_preserved, stale_page_cache_is_cleared_on_upgrade
*/
public function test_index_entries_outside_page_cache_namespace_are_not_forgotten(): void
{
$this->cache->put('extension.middleware_index', ['a'], 3600);
$this->cache->put('seo.page.abc', '<html></html>', 3600);
$this->cache->put('seo.cached_urls', [
['key' => 'seo.page.abc', 'url' => '/'],
// 오염된 엔트리 — 봇 페이지 캐시가 아닌 키를 가리킨다
['key' => 'extension.middleware_index', 'url' => '/'],
], 3600);
$this->migration->run($this->context());
$this->assertSame(['a'], $this->cache->get('extension.middleware_index'), '무관한 캐시가 삭제되었습니다.');
$this->assertNull($this->cache->get('seo.page.abc'), '봇 페이지 캐시는 삭제되어야 합니다.');
}
}