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