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:
1 parent
b505ac7ba7
commit
0dfb45951b
34 files changed
+5244
-172
No files matched your search
@@ -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('<b>', $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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'), '봇 페이지 캐시는 삭제되어야 합니다.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user