resolver = new SeoMetaResolver(new ExpressionEvaluator); // 코어 기본 설정 초기화 Config::set('g7_settings.core.seo.meta_title_suffix', ' | 그누보드7 쇼핑몰'); Config::set('g7_settings.core.seo.meta_description', '코어 기본 설명'); Config::set('g7_settings.core.seo.meta_keywords', '코어,키워드'); Config::set('g7_settings.core.seo.google_analytics_id', 'GA-12345'); Config::set('g7_settings.core.seo.google_site_verification', ''); Config::set('g7_settings.core.seo.naver_site_verification', ''); Config::set('g7_settings.core.general.site_name', '그누보드7 쇼핑몰'); } /** * 상품에 meta_title이 있으면 상품의 meta_title + 코어 suffix를 사용합니다. */ public function test_product_with_meta_title_uses_product_meta_title_and_suffix(): void { $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', 'meta_title' => '에어맥스 한정판', 'meta_description' => '나이키 에어맥스 한정판 상품', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('에어맥스 한정판', $result['title']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 상품에 meta_title이 없고 모듈 템플릿이 있으면 모듈 템플릿 + suffix를 사용합니다. */ public function test_product_without_meta_title_uses_module_template_and_suffix(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} - {commerce_name}'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트몰'); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", 'commerce_name' => '$module_settings:basic_info.shop_name', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', 'short_description' => '나이키 에어맥스', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('에어맥스 - 테스트몰', $result['title']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 상품에 meta_title이 없고 모듈 템플릿도 없으면 코어 fallback + suffix를 사용합니다. */ public function test_product_without_meta_title_no_module_template_uses_core_fallback(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', null); $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); // 타이틀은 빈 문자열 (resolveLayoutMetaTitle도 빈 seoConfig이므로 빈 결과) // description은 코어 fallback $this->assertSame('코어 기본 설명', $result['description']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 카테고리에 meta_title이 있으면 카테고리의 meta_title + suffix를 사용합니다. */ public function test_category_with_meta_title_uses_category_meta_title_and_suffix(): void { $context = [ 'category' => [ 'data' => [ 'name' => '신발', 'meta_title' => '신발 카테고리', 'meta_description' => '다양한 신발 모음', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('신발 카테고리', $result['title']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 페이지에 seo_meta.title이 있으면 페이지의 seo_meta.title + suffix를 사용합니다. */ public function test_page_with_seo_meta_title_uses_page_seo_meta_title_and_suffix(): void { $context = [ 'page' => [ 'data' => [ 'title' => '회사 소개', 'seo_meta' => [ 'title' => '회사 소개 - SEO 타이틀', 'description' => '회사 소개 SEO 설명', ], ], ], ]; $result = $this->resolver->resolve([], $context, null, null, []); $this->assertSame('회사 소개 - SEO 타이틀', $result['title']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 페이지에 seo_meta.title이 없으면 코어 fallback + suffix를 사용합니다. */ public function test_page_without_seo_meta_title_uses_core_fallback(): void { $context = [ 'page' => [ 'data' => [ 'title' => '회사 소개', ], ], ]; $result = $this->resolver->resolve([], $context, null, null, []); // 모듈 식별자 null이므로 모듈 템플릿 skip → 코어 fallback $this->assertSame('코어 기본 설명', $result['description']); $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } /** * 코어 title suffix가 비어 있으면 suffix가 없습니다. */ public function test_core_suffix_empty_no_suffix(): void { Config::set('g7_settings.core.seo.meta_title_suffix', ''); $context = [ 'product' => [ 'data' => [ 'meta_title' => '테스트 상품', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('테스트 상품', $result['title']); $this->assertSame('', $result['titleSuffix']); } /** * 모듈 템플릿 변수 {product_name}이 실제 상품명으로 치환됩니다. */ public function test_template_variable_substitution(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 구매하기'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '나이키 에어포스', 'short_description' => '나이키 에어포스 1', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('나이키 에어포스 구매하기', $result['title']); } /** * 상품에 meta_keywords가 있으면 상품의 키워드를 사용합니다. */ public function test_keywords_from_product(): void { $context = [ 'product' => [ 'data' => [ 'meta_keywords' => '나이키,에어맥스,운동화', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('나이키,에어맥스,운동화', $result['keywords']); } /** * 상품 키워드가 배열인 경우 쉼표로 결합합니다. */ public function test_keywords_array_from_product_joined(): void { $context = [ 'product' => [ 'data' => [ 'meta_keywords' => ['나이키', '에어맥스', '운동화'], ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('나이키,에어맥스,운동화', $result['keywords']); } /** * 상품 키워드가 없으면 코어 키워드를 사용합니다. */ public function test_keywords_falls_back_to_core(): void { $context = [ 'product' => [ 'data' => [ 'name' => '테스트 상품', ], ], ]; $result = $this->resolver->resolve([], $context, 'sirsoft-ecommerce', null, []); $this->assertSame('코어,키워드', $result['keywords']); } /** * Google Analytics ID가 반환됩니다. */ public function test_google_analytics_id_returned(): void { $result = $this->resolver->resolve([], [], null, null, []); $this->assertSame('GA-12345', $result['googleAnalyticsId']); } /** * OG 태그에 이미지가 있으면 og:image가 포함됩니다. */ public function test_og_tags_with_image_includes_og_image(): void { $seoConfig = [ 'og' => [ 'type' => 'product', 'title' => '{{product.data.name}}', 'description' => '{{product.data.short_description}}', 'image' => '{{product.data.image_url}}', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', 'meta_title' => '에어맥스', 'short_description' => '나이키 에어맥스', 'image_url' => 'https://example.com/images/airmax.jpg', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertStringContainsString('og:image', $result['ogTags']); $this->assertStringContainsString('https://example.com/images/airmax.jpg', $result['ogTags']); $this->assertStringContainsString('og:type', $result['ogTags']); $this->assertStringContainsString('product', $result['ogTags']); } /** * OG 태그에 이미지가 없으면 og:image가 포함되지 않습니다. */ public function test_og_tags_without_image_excludes_og_image(): void { $seoConfig = [ 'og' => [ 'type' => 'website', 'title' => '사이트 제목', 'description' => '사이트 설명', ], ]; $result = $this->resolver->resolve($seoConfig, [], null, null, []); $this->assertStringNotContainsString('og:image', $result['ogTags']); $this->assertStringContainsString('og:type', $result['ogTags']); } /** * 구조화 데이터(JSON-LD)에 @context가 포함되어 생성됩니다. */ public function test_structured_data_json_ld_generated_with_context(): void { $seoConfig = [ 'structured_data' => [ '@type' => 'Product', 'name' => '{{product.data.name}}', 'description' => '{{product.data.short_description}}', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', 'short_description' => '나이키 에어맥스', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertNotNull($result['jsonLd']); $jsonLd = json_decode($result['jsonLd'], true); $this->assertSame('https://schema.org', $jsonLd['@context']); $this->assertSame('Product', $jsonLd['@type']); $this->assertSame('에어맥스', $jsonLd['name']); $this->assertSame('나이키 에어맥스', $jsonLd['description']); } /** * 구조화 데이터가 없으면 jsonLd는 null입니다. */ public function test_no_structured_data_returns_null_json_ld(): void { $result = $this->resolver->resolve([], [], null, null, []); $this->assertNull($result['jsonLd']); } /** * 모듈 SEO가 비활성화되면 모듈 템플릿을 사용하지 않습니다. */ public function test_module_template_resolved_regardless_of_inline_enabled_flag(): void { // 모듈 SEO 활성화/비활성화는 SeoRenderer::isModuleSeoEnabled()에서 // toggle_setting 기반으로 판단 (SeoMetaResolver는 관여하지 않음) Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 상품'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '에어맥스', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); // SeoMetaResolver는 enabled 체크 없이 템플릿 해석 수행 // (활성화 체크는 SeoRenderer 단에서 toggle_setting 기반으로 수행) $this->assertSame('에어맥스 상품', $result['title']); } /** * 페이지의 seo_meta.keywords가 있으면 해당 키워드를 사용합니다. */ public function test_page_seo_meta_keywords_used(): void { $context = [ 'page' => [ 'data' => [ 'seo_meta' => [ 'keywords' => '소개,회사,정보', ], ], ], ]; $result = $this->resolver->resolve([], $context, null, null, []); $this->assertSame('소개,회사,정보', $result['keywords']); } /** * 페이지의 seo_meta.description이 있으면 해당 설명을 사용합니다. */ public function test_page_seo_meta_description_used(): void { $context = [ 'page' => [ 'data' => [ 'seo_meta' => [ 'title' => '소개 페이지', 'description' => '회사 소개 페이지 설명입니다', ], ], ], ]; $result = $this->resolver->resolve([], $context, null, null, []); $this->assertSame('회사 소개 페이지 설명입니다', $result['description']); } /** * OG 설정이 비어 있으면 ogTags는 빈 문자열입니다. */ public function test_empty_og_config_returns_empty_og_tags(): void { $result = $this->resolver->resolve([], [], null, null, []); $this->assertSame('', $result['ogTags']); } /** * 코어 설정값이 null(JSON에 null 저장)일 때도 정상 동작합니다. */ public function test_null_core_settings_do_not_cause_type_error(): void { Config::set('g7_settings.core.seo.meta_title_suffix', null); Config::set('g7_settings.core.seo.meta_description', null); Config::set('g7_settings.core.seo.meta_keywords', null); $seoConfig = [ 'og' => [ 'type' => 'website', 'title' => '사이트', 'description' => '설명', ], ]; // OG 태그의 string 타입힌트에 null이 전달되면 TypeError 발생 // 이 테스트는 null 방어 로직이 정상 동작하는지 검증 $result = $this->resolver->resolve($seoConfig, [], null, null, []); $this->assertIsString($result['title']); $this->assertIsString($result['description']); $this->assertIsString($result['ogTags']); } /** * OG 태그의 HTML이 제거되고 순수 텍스트만 남습니다. */ public function test_og_tags_strip_html_from_description(): void { $seoConfig = [ 'og' => [ 'type' => 'product', 'title' => '{{product.data.name}}', 'description' => '{{product.data.description}}', 'image' => '{{product.data.thumbnail_url}}', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '무선 마우스', 'meta_title' => '무선 마우스', 'description' => '

인체공학 디자인의 무선 마우스입니다.

', 'thumbnail_url' => 'https://example.com/mouse.jpg', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); // OG description에서 HTML 태그가 제거되어야 함 $this->assertStringNotContainsString('

', $result['ogTags']); $this->assertStringNotContainsString('', $result['ogTags']); $this->assertStringContainsString('인체공학 디자인의 무선 마우스입니다.', $result['ogTags']); } /** * description이 비어있을 때 레이아웃 og.description에서 fallback합니다. */ public function test_description_fallback_from_layout_og_description(): void { Config::set('g7_settings.core.seo.meta_description', ''); $seoConfig = [ 'og' => [ 'type' => 'product', 'title' => '{{product.data.name}}', 'description' => '{{product.data.description}}', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '무선 마우스', 'description' => '고급 무선 마우스', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, null, null, []); // meta_description 없고 모듈 템플릿 없고 코어 설명 비어있어도 // 레이아웃 og.description에서 fallback $this->assertSame('고급 무선 마우스', $result['description']); } /** * 모듈 템플릿 변수가 다국어 객체일 때 현재 로케일 값으로 치환됩니다. */ public function test_template_variable_substitution_with_localized_value(): void { app()->setLocale('ko'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 구매하기'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => [ 'name' => ['ko' => '무선 마우스', 'en' => 'Wireless Mouse'], 'short_description' => ['ko' => '고급 마우스', 'en' => 'Premium mouse'], ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('무선 마우스 구매하기', $result['title']); } /** * 구조화 데이터의 description 필드에서 HTML이 제거됩니다. */ public function test_structured_data_description_strips_html(): void { $seoConfig = [ 'structured_data' => [ '@type' => 'Product', 'name' => '{{product.data.name}}', 'description' => '{{product.data.description}}', ], ]; $context = [ 'product' => [ 'data' => [ 'name' => '무선 마우스', 'description' => '

인체공학 디자인의 무선 마우스입니다.

', ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $jsonLd = json_decode($result['jsonLd'], true); $this->assertSame('무선 마우스', $jsonLd['name']); $this->assertStringNotContainsString('

', $jsonLd['description']); $this->assertStringNotContainsString('', $jsonLd['description']); $this->assertSame('인체공학 디자인의 무선 마우스입니다.', $jsonLd['description']); } /** * 결과 배열에 모든 필수 키가 포함됩니다. */ public function test_result_contains_all_required_keys(): void { $result = $this->resolver->resolve([], [], null, null, []); $expectedKeys = [ 'title', 'titleSuffix', 'description', 'keywords', 'ogTags', 'jsonLd', 'googleAnalyticsId', 'googleVerification', 'naverVerification', ]; foreach ($expectedKeys as $key) { $this->assertArrayHasKey($key, $result, "결과에 '{$key}' 키가 존재해야 합니다"); } } // ========================================================================= // vars 해석 + page_type 선언적 설정 테스트 (10개) // ========================================================================= /** * vars의 {{expression}} 바인딩이 context에서 해석됩니다. */ public function test_resolve_vars_expression_binding(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 상품'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => ['name' => '운동화'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('운동화 상품', $result['title']); } /** * vars의 $module_settings: 접두사가 모듈 설정에서 값을 가져옵니다. */ public function test_resolve_vars_module_settings(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} - {commerce_name}'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '마이샵'); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", 'commerce_name' => '$module_settings:basic_info.shop_name', ], ]; $context = [ 'product' => [ 'data' => ['name' => '가방'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('가방 - 마이샵', $result['title']); } /** * vars의 $core_settings: 접두사가 코어 설정에서 값을 가져옵니다. */ public function test_resolve_vars_core_settings(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} | {site_name}'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", 'site_name' => '$core_settings:general.site_name', ], ]; $context = [ 'product' => [ 'data' => ['name' => '시계'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('시계 | 그누보드7 쇼핑몰', $result['title']); } /** * vars의 $query: 접두사가 쿼리 파라미터에서 값을 가져옵니다. */ public function test_resolve_vars_query_param(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_search_title', '{keyword_name} 검색결과'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_search', true); // 쿼리 파라미터 시뮬레이션 request()->merge(['q' => '나이키']); $seoConfig = [ 'page_type' => 'search', 'vars' => [ 'keyword_name' => '$query:q', ], ]; $result = $this->resolver->resolve($seoConfig, [], 'sirsoft-ecommerce', null, []); $this->assertSame('나이키 검색결과', $result['title']); } /** * vars 선언으로 치환된 결과가 모듈 설정 title 템플릿에 적용됩니다. */ public function test_substitute_vars_in_title_template(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_title', '{category_name} | {commerce_name}'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_category', true); Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '패션몰'); $seoConfig = [ 'page_type' => 'category', 'vars' => [ 'category_name' => "{{category.data.name ?? ''}}", 'commerce_name' => '$module_settings:basic_info.shop_name', ], ]; $context = [ 'category' => [ 'data' => ['name' => '신발'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('신발 | 패션몰', $result['title']); } /** * page_type이 레이아웃에서 제공되면 해당 모듈 설정 키를 사용합니다. */ public function test_page_type_from_layout(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 상세'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => ['name' => '모자'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); // page_type이 'product'이므로 meta_product_title 키 사용 $this->assertSame('모자 상세', $result['title']); } /** * vars 미선언 시 치환 없이 템플릿 원본이 반환됩니다. */ public function test_no_vars_no_substitution(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 구매'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', // vars 미선언 ]; $context = [ 'product' => [ 'data' => ['name' => '가방'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); // vars 없으므로 치환 안됨 → 템플릿 원본 반환 $this->assertSame('{product_name} 구매', $result['title']); } /** * page_type 미선언 시 모듈 템플릿을 건너뜁니다. */ public function test_no_page_type_skips_module_template(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 상품'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ // page_type 미선언 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", ], ]; $context = [ 'product' => [ 'data' => ['name' => '운동화'], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); // page_type 없으므로 Tier 2 스킵 → 코어 fallback $this->assertNotSame('운동화 상품', $result['title']); $this->assertSame('코어 기본 설명', $result['description']); } /** * vars의 표현식에서 없는 context 경로를 참조하면 빈 문자열을 반환합니다. */ public function test_vars_missing_context_returns_empty(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} 상품'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{nonexistent.data.name ?? ''}}", ], ]; $result = $this->resolver->resolve($seoConfig, [], 'sirsoft-ecommerce', null, []); // nonexistent 경로 → 빈 문자열 → ' 상품' $this->assertSame(' 상품', $result['title']); } /** * vars의 다국어 객체가 현재 로케일 값으로 해석됩니다. */ public function test_localized_value_in_expression_var(): void { app()->setLocale('ko'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_title', '{category_name} 카테고리'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_category', true); $seoConfig = [ 'page_type' => 'category', 'vars' => [ 'category_name' => "{{category.data.name ?? ''}}", ], ]; $context = [ 'category' => [ 'data' => [ 'name' => ['ko' => '전자기기', 'en' => 'Electronics'], ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('전자기기 카테고리', $result['title']); } // ========================================================================= // 플러그인 SEO 지원 테스트 (4개) // ========================================================================= /** * 플러그인 Tier 2 타이틀 템플릿이 해석됩니다. */ public function test_resolve_title_with_plugin_template(): void { Config::set('g7_settings.plugins.sirsoft-payment.seo.meta_checkout_title', '{page_name} - {payment_name}'); Config::set('g7_settings.plugins.sirsoft-payment.basic.payment_name', '간편결제'); $seoConfig = [ 'page_type' => 'checkout', 'vars' => [ 'page_name' => '결제하기', 'payment_name' => '$plugin_settings:basic.payment_name', ], ]; $result = $this->resolver->resolve($seoConfig, [], null, 'sirsoft-payment', []); $this->assertSame('결제하기 - 간편결제', $result['title']); } /** * 플러그인 Tier 2 설명 템플릿이 해석됩니다. */ public function test_resolve_description_with_plugin_template(): void { Config::set('g7_settings.plugins.sirsoft-payment.seo.meta_checkout_description', '{page_name} 페이지'); $seoConfig = [ 'page_type' => 'checkout', 'vars' => [ 'page_name' => '결제하기', ], ]; $result = $this->resolver->resolve($seoConfig, [], null, 'sirsoft-payment', []); $this->assertSame('결제하기 페이지', $result['description']); } /** * $plugin_settings: 변수 표현식이 해석됩니다. */ public function test_resolve_var_expression_with_plugin_settings(): void { Config::set('g7_settings.plugins.sirsoft-payment.seo.meta_info_title', '{gateway_name} 결제정보'); Config::set('g7_settings.plugins.sirsoft-payment.gateway.name', '카카오페이'); $seoConfig = [ 'page_type' => 'info', 'vars' => [ 'gateway_name' => '$plugin_settings:gateway.name', ], ]; $result = $this->resolver->resolve($seoConfig, [], null, 'sirsoft-payment', []); $this->assertSame('카카오페이 결제정보', $result['title']); } /** * moduleIdentifier=null, pluginIdentifier=값일 때 플러그인 경로로 해석됩니다. */ public function test_resolve_with_plugin_identifier_null_module(): void { Config::set('g7_settings.plugins.sirsoft-payment.seo.meta_checkout_title', '{page_name}'); $seoConfig = [ 'page_type' => 'checkout', 'vars' => [ 'page_name' => '주문결제', ], ]; // moduleIdentifier=null, pluginIdentifier='sirsoft-payment' $result = $this->resolver->resolve($seoConfig, [], null, 'sirsoft-payment', []); $this->assertSame('주문결제', $result['title']); // 코어 suffix 적용 확인 $this->assertSame(' | 그누보드7 쇼핑몰', $result['titleSuffix']); } // ========================================================================= // 명시적 모듈 ID ($module_settings:MODULE_ID:key) 테스트 // ========================================================================= /** * 명시적 모듈 ID가 vars에서 올바르게 해석됩니다. * moduleIdentifier가 제공되면 Tier 2 템플릿에서 명시적 ID vars를 사용합니다. */ public function test_resolve_vars_explicit_module_id_when_module_null(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_index_title', '{commerce_name} 쇼핑몰'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_index', true); Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트몰'); $seoConfig = [ 'page_type' => 'index', 'vars' => [ // 명시적 모듈 ID 포맷: $module_settings:MODULE_ID:key 'commerce_name' => '$module_settings:sirsoft-ecommerce:basic_info.shop_name', ], ]; // moduleIdentifier 제공 — Tier 2 템플릿 해석 + 명시적 모듈 ID vars 사용 $result = $this->resolver->resolve($seoConfig, [], 'sirsoft-ecommerce', null, []); $this->assertSame('테스트몰 쇼핑몰', $result['title']); } /** * 명시적 모듈 ID 없이 기존 moduleIdentifier 컨텍스트로 해석됩니다 (하위 호환). */ public function test_resolve_vars_context_module_id_backward_compatible(): void { Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{product_name} - {commerce_name}'); Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true); Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '패션몰'); $seoConfig = [ 'page_type' => 'product', 'vars' => [ 'product_name' => "{{product.data.name ?? ''}}", // 명시적 ID 없음 → moduleIdentifier='sirsoft-ecommerce' 사용 'commerce_name' => '$module_settings:basic_info.shop_name', ], ]; $context = [ 'product' => ['data' => ['name' => '운동화']], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $this->assertSame('운동화 - 패션몰', $result['title']); } // ========================================================================= // 구조화 데이터 빈 하위 객체 제거 테스트 // ========================================================================= /** * aggregateRating의 필수 값이 모두 비어있으면 JSON-LD에서 제거됩니다. */ public function test_structured_data_removes_empty_aggregate_rating(): void { $seoConfig = [ 'structured_data' => [ '@type' => 'Product', 'name' => '{{product.data.name}}', 'aggregateRating' => [ '@type' => 'AggregateRating', 'ratingValue' => '{{reviews.data.rating_stats.avg ?? \'\'}}', 'reviewCount' => '{{reviews.data.reviews.total ?? \'\'}}', 'bestRating' => '5', 'worstRating' => '1', ], ], ]; // reviews 데이터가 없는 상황 (리뷰 0건) $context = [ 'product' => ['data' => ['name' => '에어맥스']], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $jsonLd = json_decode($result['jsonLd'], true); $this->assertSame('에어맥스', $jsonLd['name']); // aggregateRating은 ratingValue, reviewCount가 빈 문자열이므로 제거됨 $this->assertArrayNotHasKey('aggregateRating', $jsonLd); } /** * aggregateRating에 유효한 값이 있으면 JSON-LD에 포함됩니다. */ public function test_structured_data_keeps_valid_aggregate_rating(): void { $seoConfig = [ 'structured_data' => [ '@type' => 'Product', 'name' => '{{product.data.name}}', 'aggregateRating' => [ '@type' => 'AggregateRating', 'ratingValue' => '{{reviews.data.rating_stats.avg}}', 'reviewCount' => '{{reviews.data.reviews.total}}', 'bestRating' => '5', 'worstRating' => '1', ], ], ]; $context = [ 'product' => ['data' => ['name' => '에어맥스']], 'reviews' => [ 'data' => [ 'rating_stats' => ['avg' => '4.5'], 'reviews' => ['total' => '32'], ], ], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $jsonLd = json_decode($result['jsonLd'], true); $this->assertSame('에어맥스', $jsonLd['name']); $this->assertArrayHasKey('aggregateRating', $jsonLd); $this->assertSame('AggregateRating', $jsonLd['aggregateRating']['@type']); $this->assertSame('4.5', $jsonLd['aggregateRating']['ratingValue']); $this->assertSame('32', $jsonLd['aggregateRating']['reviewCount']); } /** * @type이 없는 일반 하위 객체는 빈 값이어도 제거되지 않습니다. */ public function test_structured_data_keeps_non_typed_empty_objects(): void { $seoConfig = [ 'structured_data' => [ '@type' => 'Product', 'name' => '{{product.data.name}}', 'offers' => [ '@type' => 'Offer', 'price' => '{{product.data.selling_price}}', 'priceCurrency' => 'KRW', ], ], ]; $context = [ 'product' => ['data' => ['name' => '에어맥스', 'selling_price' => '129000']], ]; $result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []); $jsonLd = json_decode($result['jsonLd'], true); $this->assertArrayHasKey('offers', $jsonLd); $this->assertSame('129000', $jsonLd['offers']['price']); } // ========================================================================= // _seo context 참조 테스트 // ========================================================================= /** * _seo.product.title이 context에 있으면 Tier 2로 사용됩니다. */ public function test_seo_context_title_used_as_tier2(): void { $seoConfig = [ 'enabled' => true, 'page_type' => 'product', ]; $context = [ '_seo' => [ 'product' => [ 'title' => '테스트쇼핑몰 - 에어맥스', 'description' => '에어맥스 설명', ], ], 'product' => ['data' => ['name' => '에어맥스']], ]; $result = $this->resolver->resolve($seoConfig, $context, null, null, []); $this->assertEquals('테스트쇼핑몰 - 에어맥스', $result['title']); $this->assertEquals('에어맥스 설명', $result['description']); } /** * Tier 3 (resource meta_title)가 _seo context보다 우선합니다. */ public function test_resource_meta_title_overrides_seo_context(): void { $seoConfig = [ 'enabled' => true, 'page_type' => 'product', ]; $context = [ '_seo' => [ 'product' => [ 'title' => '테스트쇼핑몰 - 에어맥스', 'description' => '쇼핑몰 설명', ], ], 'product' => ['data' => [ 'name' => '에어맥스', 'meta_title' => '커스텀 SEO 타이틀', 'meta_description' => '커스텀 SEO 설명', ]], ]; $result = $this->resolver->resolve($seoConfig, $context, null, null, []); // Tier 3가 Tier 2(_seo)보다 우선 $this->assertEquals('커스텀 SEO 타이틀', $result['title']); $this->assertEquals('커스텀 SEO 설명', $result['description']); } /** * _seo context 없고 moduleIdentifier도 null이면 빈 타이틀로 fallback합니다. */ public function test_no_seo_context_and_no_module_falls_through(): void { $seoConfig = [ 'enabled' => true, 'page_type' => 'product', ]; $context = [ 'product' => ['data' => ['name' => '에어맥스']], ]; $result = $this->resolver->resolve($seoConfig, $context, null, null, []); // _seo도 없고 moduleIdentifier도 null → Tier 2 스킵 → 빈 문자열 $this->assertEquals('', $result['title']); } }