일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
289 lines
13 KiB
PHP
289 lines
13 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Seo;
|
|
|
|
use App\Seo\SeoMetaResolver;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* SEO 레이아웃 JSON 구조 검증 테스트
|
|
*
|
|
* 주요 레이아웃 JSON 파일이 올바른 meta.seo 속성을 포함하고 있는지 확인합니다.
|
|
* 실제 파일을 읽어 구조적 정합성을 검증하는 정적 분석 테스트입니다.
|
|
*/
|
|
class SeoPageRenderingTest extends TestCase
|
|
{
|
|
/** @var string 레이아웃 파일 기본 경로 */
|
|
private string $layoutBasePath;
|
|
|
|
/**
|
|
* 테스트 초기화 - 레이아웃 파일 기본 경로를 설정합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->layoutBasePath = base_path('templates/_bundled/sirsoft-basic/layouts');
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 JSON 파일을 읽어 배열로 반환합니다.
|
|
*
|
|
* @param string $relativePath 레이아웃 기본 경로 기준 상대 경로
|
|
* @return array 디코딩된 레이아웃 배열
|
|
*/
|
|
private function readLayout(string $relativePath): array
|
|
{
|
|
$filePath = $this->layoutBasePath.'/'.$relativePath;
|
|
|
|
$this->assertFileExists($filePath, "레이아웃 파일이 존재해야 합니다: {$relativePath}");
|
|
|
|
$content = file_get_contents($filePath);
|
|
$this->assertNotFalse($content, "레이아웃 파일을 읽을 수 있어야 합니다: {$relativePath}");
|
|
|
|
$decoded = json_decode($content, true);
|
|
$this->assertNotNull($decoded, "레이아웃 파일이 유효한 JSON이어야 합니다: {$relativePath}");
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* home.json 레이아웃에 SEO 메타가 올바르게 설정되어 있는지 확인합니다.
|
|
*/
|
|
public function test_home_layout_has_seo_meta(): void
|
|
{
|
|
$layout = $this->readLayout('home.json');
|
|
|
|
// meta.seo 존재 확인
|
|
$this->assertArrayHasKey('meta', $layout, 'home.json에 meta 키가 존재해야 합니다');
|
|
$this->assertArrayHasKey('seo', $layout['meta'], 'home.json에 meta.seo 키가 존재해야 합니다');
|
|
|
|
$seo = $layout['meta']['seo'];
|
|
|
|
// enabled 확인
|
|
$this->assertArrayHasKey('enabled', $seo, 'home.json seo에 enabled 키가 존재해야 합니다');
|
|
$this->assertTrue($seo['enabled'], 'home.json의 SEO는 활성화되어야 합니다');
|
|
|
|
// priority 확인 (홈페이지는 최우선)
|
|
$this->assertArrayHasKey('priority', $seo, 'home.json seo에 priority 키가 존재해야 합니다');
|
|
$this->assertSame(1.0, $seo['priority'], 'home.json의 SEO priority는 1.0이어야 합니다');
|
|
|
|
// changefreq 확인
|
|
$this->assertArrayHasKey('changefreq', $seo, 'home.json seo에 changefreq 키가 존재해야 합니다');
|
|
|
|
// og 확인
|
|
$this->assertArrayHasKey('og', $seo, 'home.json seo에 og 키가 존재해야 합니다');
|
|
$this->assertSame('website', $seo['og']['type'], 'home.json og type은 website여야 합니다');
|
|
|
|
// structured_data 확인 (WebSite 타입)
|
|
$this->assertArrayHasKey('structured_data', $seo, 'home.json seo에 structured_data 키가 존재해야 합니다');
|
|
$this->assertSame('WebSite', $seo['structured_data']['@type'], 'home.json structured_data @type은 WebSite여야 합니다');
|
|
|
|
// data_sources 확인
|
|
$this->assertArrayHasKey('data_sources', $seo, 'home.json seo에 data_sources 키가 존재해야 합니다');
|
|
$this->assertIsArray($seo['data_sources']);
|
|
}
|
|
|
|
/**
|
|
* shop/show.json 레이아웃에 Product structured_data가 설정되어 있는지 확인합니다.
|
|
*/
|
|
public function test_shop_show_layout_has_product_structured_data(): void
|
|
{
|
|
$layout = $this->readLayout('shop/show.json');
|
|
|
|
// meta.seo 존재 확인
|
|
$this->assertArrayHasKey('meta', $layout, 'shop/show.json에 meta 키가 존재해야 합니다');
|
|
$this->assertArrayHasKey('seo', $layout['meta'], 'shop/show.json에 meta.seo 키가 존재해야 합니다');
|
|
|
|
$seo = $layout['meta']['seo'];
|
|
|
|
// SEO 활성화 확인
|
|
$this->assertTrue($seo['enabled'], 'shop/show.json의 SEO는 활성화되어야 합니다');
|
|
|
|
// structured_data 는 모듈(EcommerceModule::seoStructuredData)이 owned —
|
|
// 레이아웃 JSON 직접 선언 금지. 모듈 메서드가 Product/Offer 스키마를 만든다.
|
|
$this->assertArrayNotHasKey('structured_data', $seo, 'shop/show.json 의 structured_data 는 EcommerceModule::seoStructuredData() 로 이전됨 (모듈 ownership)');
|
|
|
|
// og 타입 확인
|
|
$this->assertArrayHasKey('og', $seo, 'shop/show.json seo에 og 키가 존재해야 합니다');
|
|
$this->assertSame('product', $seo['og']['type'], 'shop/show.json og type은 product여야 합니다');
|
|
|
|
// data_sources 확인 (product 데이터소스 참조)
|
|
$this->assertArrayHasKey('data_sources', $seo, 'shop/show.json seo에 data_sources 키가 존재해야 합니다');
|
|
$this->assertContains('product', $seo['data_sources'], 'shop/show.json seo data_sources에 product가 포함되어야 합니다');
|
|
}
|
|
|
|
/**
|
|
* board/show.json 레이아웃에 Article structured_data가 설정되어 있는지 확인합니다.
|
|
*/
|
|
public function test_board_show_layout_has_article_structured_data(): void
|
|
{
|
|
$layout = $this->readLayout('board/show.json');
|
|
|
|
// meta.seo 존재 확인
|
|
$this->assertArrayHasKey('meta', $layout, 'board/show.json에 meta 키가 존재해야 합니다');
|
|
$this->assertArrayHasKey('seo', $layout['meta'], 'board/show.json에 meta.seo 키가 존재해야 합니다');
|
|
|
|
$seo = $layout['meta']['seo'];
|
|
|
|
// SEO 활성화 확인
|
|
$this->assertTrue($seo['enabled'], 'board/show.json의 SEO는 활성화되어야 합니다');
|
|
|
|
// structured_data 는 모듈(BoardModule::seoStructuredData)이 owned —
|
|
// 레이아웃 JSON 직접 선언 금지. 모듈 메서드가 Article 스키마를 만든다.
|
|
$this->assertArrayNotHasKey('structured_data', $seo, 'board/show.json 의 structured_data 는 BoardModule::seoStructuredData() 로 이전됨 (모듈 ownership)');
|
|
|
|
// og 타입 확인
|
|
$this->assertArrayHasKey('og', $seo, 'board/show.json seo에 og 키가 존재해야 합니다');
|
|
$this->assertSame('article', $seo['og']['type'], 'board/show.json og type은 article이어야 합니다');
|
|
|
|
// data_sources 확인 (post 데이터소스 참조)
|
|
$this->assertArrayHasKey('data_sources', $seo, 'board/show.json seo에 data_sources 키가 존재해야 합니다');
|
|
$this->assertContains('post', $seo['data_sources'], 'board/show.json seo data_sources에 post가 포함되어야 합니다');
|
|
}
|
|
|
|
/**
|
|
* search/index.json 레이아웃에 SEO 설정이 최소화(낮은 priority)되어 있는지 확인합니다.
|
|
*/
|
|
public function test_search_layout_has_seo_with_minimal_priority(): void
|
|
{
|
|
$layout = $this->readLayout('search/index.json');
|
|
|
|
// meta.seo 존재 확인
|
|
$this->assertArrayHasKey('meta', $layout, 'search/index.json에 meta 키가 존재해야 합니다');
|
|
$this->assertArrayHasKey('seo', $layout['meta'], 'search/index.json에 meta.seo 키가 존재해야 합니다');
|
|
|
|
$seo = $layout['meta']['seo'];
|
|
|
|
// priority 확인 (검색 페이지는 낮은 우선순위)
|
|
$this->assertArrayHasKey('priority', $seo, 'search/index.json seo에 priority 키가 존재해야 합니다');
|
|
$this->assertLessThanOrEqual(0.5, $seo['priority'], '검색 페이지 SEO priority는 0.5 이하여야 합니다');
|
|
|
|
// data_sources 확인 — 봇에게도 실제 검색 결과를 보여준다.
|
|
// 종전에는 비워 두어 봇에게 빈 껍데기 페이지가 나갔다 (수집은 허용하면서 내용은 없는 상태).
|
|
$this->assertArrayHasKey('data_sources', $seo, 'search/index.json seo에 data_sources 키가 존재해야 합니다');
|
|
$this->assertContains(
|
|
'searchResults',
|
|
$seo['data_sources'],
|
|
'검색 페이지 SEO data_sources에 searchResults가 포함되어야 합니다 (미포함 시 봇에게 빈 결과 목록이 나갑니다)'
|
|
);
|
|
|
|
// structured_data 확인 (SearchResultsPage)
|
|
$this->assertArrayHasKey('structured_data', $seo, 'search/index.json seo에 structured_data 키가 존재해야 합니다');
|
|
$this->assertSame(
|
|
'SearchResultsPage',
|
|
$seo['structured_data']['@type'],
|
|
'search/index.json structured_data @type은 SearchResultsPage여야 합니다'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 모든 주요 레이아웃의 SEO priority 값이 유효한 범위(0~1)인지 확인합니다.
|
|
*/
|
|
public function test_all_layouts_have_valid_seo_priority_range(): void
|
|
{
|
|
$layoutFiles = [
|
|
'home.json',
|
|
'shop/show.json',
|
|
'board/show.json',
|
|
'search/index.json',
|
|
];
|
|
|
|
foreach ($layoutFiles as $file) {
|
|
$layout = $this->readLayout($file);
|
|
|
|
if (isset($layout['meta']['seo']['priority'])) {
|
|
$priority = $layout['meta']['seo']['priority'];
|
|
$this->assertGreaterThanOrEqual(0, $priority, "{$file}의 SEO priority는 0 이상이어야 합니다");
|
|
$this->assertLessThanOrEqual(1, $priority, "{$file}의 SEO priority는 1 이하여야 합니다");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 모든 주요 레이아웃의 SEO changefreq 값이 유효한 값인지 확인합니다.
|
|
*/
|
|
public function test_all_layouts_have_valid_seo_changefreq(): void
|
|
{
|
|
$validValues = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
|
$layoutFiles = [
|
|
'home.json',
|
|
'shop/show.json',
|
|
'board/show.json',
|
|
'search/index.json',
|
|
];
|
|
|
|
foreach ($layoutFiles as $file) {
|
|
$layout = $this->readLayout($file);
|
|
|
|
if (isset($layout['meta']['seo']['changefreq'])) {
|
|
$changefreq = $layout['meta']['seo']['changefreq'];
|
|
$this->assertContains(
|
|
$changefreq,
|
|
$validValues,
|
|
"{$file}의 SEO changefreq '{$changefreq}'는 유효한 값이어야 합니다"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회귀: SEO 활성 레이아웃 × 다국어 JSON array 코어 설정 매트릭스.
|
|
*
|
|
* 모든 enabled=true 레이아웃 의 meta.seo 를 SeoMetaResolver.resolve() 에 통과시키되,
|
|
* 코어 설정값(site_name 등) 이 다국어 JSON 배열로 저장된 환경을 시뮬레이션 — 이전 회귀
|
|
* "Array to string conversion" 의 광범위 재발 방지.
|
|
*/
|
|
public function test_all_enabled_layouts_resolve_without_throw_under_multilingual_settings(): void
|
|
{
|
|
// 코어 설정을 다국어 JSON array 로 시뮬레이션 (운영자가 MultilingualInput 사용 시)
|
|
Config::set('g7_settings.core.general.site_name', [
|
|
'ko' => '한글몰', 'en' => 'KoreanMall',
|
|
]);
|
|
Config::set('g7_settings.core.seo.og_default_site_name', '');
|
|
Config::set('g7_settings.core.seo.og_image_default_width', 1200);
|
|
Config::set('g7_settings.core.seo.og_image_default_height', 630);
|
|
Config::set('g7_settings.core.seo.twitter_default_card', 'summary_large_image');
|
|
Config::set('g7_settings.core.seo.twitter_default_site', '');
|
|
Config::set('g7_settings.core.seo.meta_title_suffix', '');
|
|
Config::set('g7_settings.core.seo.meta_description', '');
|
|
Config::set('g7_settings.core.seo.meta_keywords', '');
|
|
app()->setLocale('ko');
|
|
|
|
$resolver = app(SeoMetaResolver::class);
|
|
$rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->layoutBasePath));
|
|
|
|
$errors = [];
|
|
foreach ($rii as $file) {
|
|
if ($file->isDir() || $file->getExtension() !== 'json') {
|
|
continue;
|
|
}
|
|
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen($this->layoutBasePath) + 1));
|
|
if (str_starts_with($rel, 'partials/') || str_starts_with($rel, 'modals/')
|
|
|| str_starts_with($rel, 'errors/') || str_starts_with(basename($rel), '_')) {
|
|
continue;
|
|
}
|
|
|
|
$decoded = json_decode((string) file_get_contents($file->getPathname()), true);
|
|
$seoConfig = $decoded['meta']['seo'] ?? null;
|
|
if (! is_array($seoConfig) || ! ($seoConfig['enabled'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// 빈 컨텍스트 — 표현식 평가에서 empty fallback 으로 처리
|
|
$resolver->resolve($seoConfig, [], null, null, []);
|
|
} catch (\Throwable $e) {
|
|
$errors[] = "{$rel}: ".$e->getMessage();
|
|
}
|
|
}
|
|
|
|
$this->assertEmpty(
|
|
$errors,
|
|
"다국어 array 코어 설정 환경에서 다음 레이아웃의 SEO resolve 가 throw 했습니다:\n - "
|
|
.implode("\n - ", $errors)
|
|
);
|
|
}
|
|
}
|