Files
Gnuboard7/tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php
T
HeuJung 0dfb45951b fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

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

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
2026-07-27 10:03:06 +09:00

197 lines
7.3 KiB
PHP

<?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;
}
}