Files
Gnuboard7/tests/Unit/Seo/SeoMetaResolverTest.php
T
HeuJung 1ba67ca7cf feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가
카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을
코어+3모듈+2템플릿에서 해소.

- 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용)
 + seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인
- board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼,
 saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지),
 filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝
- ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정
- sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종)
- 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정
2026-08-22 22:21:54 +09:00

1786 lines
67 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Seo\ExpressionEvaluator;
use App\Seo\SeoMetaResolver;
use App\Services\SettingsService;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
/**
* SeoMetaResolver 단위 테스트
*
* 3계층 캐스케이드(리소스 → 모듈 → 코어) SEO 메타 해석 기능을 테스트합니다.
* g7_core_settings, g7_module_settings는 Config 파사드 기반이므로 Config::set()으로 모킹합니다.
*/
class SeoMetaResolverTest extends TestCase
{
private SeoMetaResolver $resolver;
/**
* 테스트 초기화 - SeoMetaResolver 인스턴스를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->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', '');
// 운영자가 로컬에 저장한 값(storage/app/settings/seo.json)이 부팅 시 config 로
// 실려 들어오므로, 미고정 시 og site_name fallback 단언이 환경에 좌우된다.
Config::set('g7_settings.core.seo.og_default_site_name', '');
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']);
}
/**
* JSON-LD 는 `<script type="application/ld+json">` 안에 임베드되므로, 사용자
* 제어 값(검색어 등)의 `</script>` 가 스크립트 컨텍스트를 조기 종료해 실행 가능한
* script 요소를 재생성하지 못하도록 `<`·`>` 를 유니코드 이스케이프해야 한다.
* (봇 렌더는 _escaped_fragment_ 로 일반 UA 도 강제되므로 반사형 XSS 벡터가 된다.)
*/
public function test_structured_data_escapes_script_breakout_in_json_ld(): void
{
$seoConfig = [
'structured_data' => [
'@type' => 'SearchResultsPage',
'name' => 'G7 - {{query.q}}',
],
];
$context = ['query' => ['q' => '</script><script>alert(1)</script>']];
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
// 원문 태그가 그대로 실리면 스크립트 컨텍스트가 조기 종료돼 실행 가능한 script 요소가
// 재생성된다(반사형 XSS). JSON_HEX_TAG 가 태그 문자를 유니코드 이스케이프하므로 산출물에는
// 리터럴 태그 열림/닫힘 문자가 없어야 한다.
$this->assertDoesNotMatchRegularExpression('#</?script#', $result['jsonLd']);
$this->assertStringContainsString(chr(0x5C).'u003C', $result['jsonLd']);
// 이스케이프에도 불구하고 JSON 파싱 시 원래 값이 복원된다(구조화 데이터 의미 보존).
$decoded = json_decode($result['jsonLd'], true);
$this->assertSame('G7 - </script><script>alert(1)</script>', $decoded['name']);
}
/**
* 구조화 데이터가 없으면 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 설정이 비어있어도 코어 설정 기반 기본 og:type/og:site_name 등이 항상 출력됩니다.
*
* (이전 회귀: og 미선언 시 빈 문자열 반환 — Slack/Facebook 미리보기 무시)
* 신규 동작: og:type=website 기본값 + 코어 설정의 site_name fallback 으로 최소 보장.
*/
public function test_empty_og_config_emits_default_type_and_site_name(): void
{
$result = $this->resolver->resolve([], [], null, null, []);
// og 배열은 항상 채워짐
$this->assertSame('website', $result['og']['type']);
$this->assertSame('그누보드7 쇼핑몰', $result['og']['site_name']);
// ogTags HTML 도 최소 og:type 은 포함
$this->assertStringContainsString('og:type" content="website"', $result['ogTags']);
$this->assertStringContainsString('og:site_name" content="그누보드7 쇼핑몰"', $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' => '<p>인체공학 디자인의 <strong>무선 마우스</strong>입니다.</p>',
'thumbnail_url' => 'https://example.com/mouse.jpg',
],
],
];
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
// OG description에서 HTML 태그가 제거되어야 함
$this->assertStringNotContainsString('<p>', $result['ogTags']);
$this->assertStringNotContainsString('<strong>', $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' => '<p>인체공학 디자인의 <strong>무선 마우스</strong>입니다.</p>',
],
],
];
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
$jsonLd = json_decode($result['jsonLd'], true);
$this->assertSame('무선 마우스', $jsonLd['name']);
$this->assertStringNotContainsString('<p>', $jsonLd['description']);
$this->assertStringNotContainsString('<strong>', $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 경로 → 빈 문자열 → '상품' (선두 공백 자동 trim — substituteVars cleanup)
$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']);
}
/**
* resolve()는 og 데이터를 배열로 반환합니다 (HTML 렌더 지연 패턴).
*/
public function test_resolve_returns_og_data_as_array(): void
{
$seoConfig = [
'og' => [
'type' => 'product',
'title' => '{{product.data.name}}',
'image' => 'https://example.com/p.jpg',
],
];
$context = ['product' => ['data' => ['name' => '에어맥스']]];
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
$this->assertIsArray($result['og']);
$this->assertSame('product', $result['og']['type']);
$this->assertSame('에어맥스', $result['og']['title']);
$this->assertSame('https://example.com/p.jpg', $result['og']['image']);
}
/**
* og:image:width / og:image:height 가 코어 설정 fallback 으로 채워집니다.
*/
public function test_og_image_dimensions_fallback_to_core_settings(): void
{
Config::set('g7_settings.core.seo.og_image_default_width', 1200);
Config::set('g7_settings.core.seo.og_image_default_height', 630);
$seoConfig = [
'og' => [
'type' => 'product',
'title' => 'p',
'image' => 'https://example.com/p.jpg',
],
];
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertSame(1200, $result['og']['image_width']);
$this->assertSame(630, $result['og']['image_height']);
$this->assertStringContainsString('og:image:width" content="1200"', $result['ogTags']);
$this->assertStringContainsString('og:image:height" content="630"', $result['ogTags']);
}
/**
* og:site_name 은 코어 설정 fallback (general.site_name) 을 사용합니다.
*/
public function test_og_site_name_fallback_to_core_general_site_name(): void
{
Config::set('g7_settings.core.seo.og_default_site_name', '');
Config::set('g7_settings.core.general.site_name', 'GNUBOARD.NET');
$seoConfig = [
'og' => [
'type' => 'website',
'title' => 'Home',
],
];
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertSame('GNUBOARD.NET', $result['og']['site_name']);
$this->assertStringContainsString('og:site_name" content="GNUBOARD.NET"', $result['ogTags']);
}
/**
* Twitter 카드 데이터가 OG 데이터를 fallback 으로 사용합니다.
*/
public function test_twitter_data_falls_back_to_og(): void
{
Config::set('g7_settings.core.seo.twitter_default_card', 'summary_large_image');
$seoConfig = [
'og' => [
'type' => 'product',
'title' => '에어맥스',
'description' => '나이키 에어맥스',
'image' => 'https://example.com/p.jpg',
],
];
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertSame('summary_large_image', $result['twitter']['card']);
$this->assertSame('에어맥스', $result['twitter']['title']);
$this->assertSame('https://example.com/p.jpg', $result['twitter']['image']);
$this->assertStringContainsString('twitter:card" content="summary_large_image"', $result['twitterTags']);
$this->assertStringContainsString('twitter:image" content="https://example.com/p.jpg"', $result['twitterTags']);
}
/**
* og.extra 배열의 자유 메타태그가 출력에 포함됩니다.
*/
public function test_og_extra_meta_tags_rendered(): void
{
$seoConfig = [
'og' => [
'type' => 'product',
'title' => 'p',
'extra' => [
['property' => 'product:price:amount', 'content' => '50000'],
['property' => 'product:price:currency', 'content' => 'KRW'],
],
],
];
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertStringContainsString('product:price:amount" content="50000"', $result['ogTags']);
$this->assertStringContainsString('product:price:currency" content="KRW"', $result['ogTags']);
}
/**
* 회귀: 모듈 템플릿 "{commerce_name} - {product_name}" 에서
* commerce_name 이 빈 값일 때 선두 "- " 가 남는 회귀를 방지합니다.
*
* issue#300: og:title="- 무선 마우스 #99" 슬랙/페이스북 미리보기 제목 깨짐
*/
public function test_module_template_title_no_leading_dash_when_var_empty(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{commerce_name} - {product_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' => '무선 마우스 #99']]];
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
$this->assertSame('무선 마우스 #99', $result['title'], '선두 dash 가 제거되어야 합니다');
$this->assertStringNotContainsString('- 무선 마우스', $result['title']);
}
/**
* 회귀: 옵셔널 그룹 [{var} - ] 신택스 — var 채워지면 그룹 keep, 비면 통째 drop.
*/
public function test_module_template_title_optional_group_filled(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '[{commerce_name} - ]{product_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']);
}
public function test_module_template_title_optional_group_empty_var_dropped(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '[{commerce_name} - ]{product_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']);
}
/**
* resolveStructuredDataArray 는 배열 형태로 반환하여 hook 변경이 가능합니다.
*/
public function test_structured_data_returns_array_for_hook_modification(): void
{
$seoConfig = [
'structured_data' => [
'@type' => 'Product',
'name' => '{{product.data.name}}',
],
];
$context = ['product' => ['data' => ['name' => '에어맥스']]];
$array = $this->resolver->resolveStructuredDataArray($seoConfig, $context);
$this->assertIsArray($array);
$this->assertSame('Product', $array['@type']);
$this->assertSame('에어맥스', $array['name']);
// hook 이 array 를 수정 후 다시 JSON 렌더 가능한지
$array['offers'] = ['@type' => 'Offer', 'price' => '50000'];
$json = $this->resolver->renderStructuredJson($array);
$this->assertNotNull($json);
$this->assertStringContainsString('"offers"', $json);
$this->assertStringContainsString('@context', $json);
}
// ── og:image 사이트 기본값 폴백 (공개 이슈 #22) ──────────────
/**
* getOgDefaultImageUrl 이 지정 값을 돌려주도록 SettingsService 를 스텁합니다.
*
* @param string|null $url 기본 OG 이미지 URL
*/
private function stubOgDefaultImage(?string $url): void
{
$mock = \Mockery::mock(SettingsService::class);
$mock->shouldReceive('getOgDefaultImageUrl')->andReturn($url);
$this->app->instance(SettingsService::class, $mock);
}
/**
* 레이아웃 og.image 선언이 있으면 사이트 기본값을 쓰지 않아야 합니다 (선언 우선).
*
* @scenario image_chain=layout_declared, setting_state=saved
*
* @effects layout_declaration_wins_over_site_default
*/
public function test_og_image_declaration_wins_over_site_default(): void
{
$this->stubOgDefaultImage('/storage/settings/site-default.png');
$result = $this->resolver->resolveOgData(
['og' => ['image' => '/storage/posts/declared.jpg']],
[],
'Fallback Title',
'Fallback Description'
);
$this->assertStringContainsString('/storage/posts/declared.jpg', $result['image']);
$this->assertStringNotContainsString('site-default', $result['image']);
}
/**
* 선언이 빈 값이면 사이트 기본 OG 이미지로 폴백하고 secure_url 도 파생되어야 합니다.
*
* @scenario image_chain=site_default, setting_state=saved
*
* @effects empty_declaration_falls_back_to_site_default
*/
public function test_og_image_falls_back_to_site_default_and_derives_secure_url(): void
{
// absoluteUrl 은 부팅 시점 URL 루트를 쓰므로 강제 지정으로 고정한다
URL::forceRootUrl('https://shop.example.com');
URL::forceScheme('https');
$this->stubOgDefaultImage('/storage/settings/site-default.png');
try {
$result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D');
} finally {
URL::forceRootUrl(null);
URL::forceScheme(null);
}
$this->assertSame('https://shop.example.com/storage/settings/site-default.png', $result['image']);
$this->assertSame(
$result['image'],
$result['image_secure_url'],
'secure_url 은 폴백 적용 후의 image 를 기준으로 파생되어야 합니다.'
);
}
/**
* 선언도 기본값도 없으면 image 가 빈 값이어야 합니다 (태그 미출력).
*
* @scenario image_chain=none, setting_state=absent
*
* @effects no_image_emits_nothing
*/
public function test_og_image_empty_when_no_declaration_and_no_default(): void
{
$this->stubOgDefaultImage(null);
$result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D');
$this->assertSame('', $result['image']);
$this->assertSame('', $result['image_secure_url']);
}
/**
* 회귀: 코어 설정 site_name 이 다국어 JSON 배열로 저장된 경우에도
* resolveOgData 가 'Array to string conversion' throw 없이 정상 동작.
*
* 운영자가 일반 설정 site_name 을 MultilingualInput 으로 저장하면
* config 값이 ["ko" => "...", "en" => "..."] 배열이 됨.
* 이전 회귀: SeoRenderer 가 봇 요청에서 항상 Array to string ErrorException → SPA fallback.
*/
public function test_resolve_handles_multilingual_array_in_core_settings(): void
{
// site_name 이 다국어 JSON 배열 (운영자가 MultilingualInput 사용 시)
Config::set('g7_settings.core.general.site_name', [
'ko' => '한글몰',
'en' => 'EnglishMall',
]);
Config::set('g7_settings.core.seo.og_default_site_name', '');
app()->setLocale('ko');
$seoConfig = [
'og' => [
'type' => 'product',
'title' => 'Product',
],
];
// throw 없이 정상 반환되어야 함
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertIsArray($result['og']);
$this->assertSame('한글몰', $result['og']['site_name']);
$this->assertStringContainsString('og:site_name" content="한글몰"', $result['ogTags']);
}
/**
* 회귀: 데이터소스 컬럼(meta_title/meta_description/meta_keywords) 이 다국어 JSON array 인 경우에도 throw 없이 동작.
*
* Product/Post 의 DB 컬럼이 multilingual JSON ({"ko":..., "en":...}) 으로 저장된 환경에서
* getResourceMetaField / getPageSeoMetaField / resolveKeywords 의 `(string) $value` 가
* "Array to string conversion" → SPA fallback 회귀 차단.
*/
public function test_resolve_handles_multilingual_array_in_resource_meta_columns(): void
{
app()->setLocale('ko');
$context = [
'product' => [
'data' => [
'name' => '에어맥스',
'meta_title' => ['ko' => '에어맥스 한정판', 'en' => 'AirMax Limited'],
'meta_description' => ['ko' => '나이키 에어맥스 한정판 상품', 'en' => 'Limited edition'],
'meta_keywords' => ['ko' => '운동화,에어맥스,한정판', 'en' => 'sneakers,airmax'],
],
],
];
// throw 없이 정상 반환
$result = $this->resolver->resolve([], $context, null, null, []);
$this->assertSame('에어맥스 한정판', $result['title']);
$this->assertSame('나이키 에어맥스 한정판 상품', $result['description']);
$this->assertSame('운동화,에어맥스,한정판', $result['keywords']);
}
/**
* 회귀: 페이지 seo_meta 객체 안에 다국어 JSON array 가 있어도 throw 없이 동작.
*/
public function test_resolve_handles_multilingual_array_in_page_seo_meta(): void
{
app()->setLocale('ko');
$context = [
'page' => [
'data' => [
'seo_meta' => [
'title' => ['ko' => '회사 소개', 'en' => 'About'],
'description' => ['ko' => '저희 회사를 소개합니다', 'en' => 'About us'],
'keywords' => ['ko' => '회사,소개', 'en' => 'about,company'],
],
],
],
];
$result = $this->resolver->resolve([], $context, null, null, []);
$this->assertSame('회사 소개', $result['title']);
$this->assertSame('저희 회사를 소개합니다', $result['description']);
}
/**
* 회귀: substituteVars 가 다국어 JSON 배열을 변수 값으로 받은 경우에도 throw 없이 동작.
*
* 운영자가 var 의 source(예: $module_settings:basic_info.shop_name) 가 다국어 array 를
* 반환하는 환경에서, 기존 substituteVars 의 `(string) $resolvedVars[$matches[1]]` 가
* "Array to string conversion" → SeoMiddleware catch → SPA fallback 회귀를 차단.
*
* (이 회귀는 #300 작업의 substituteVars 강화에서 도입됨 — 이전 str_replace 기반은
* array 받아도 fatal 안 났음.)
*/
public function test_substitute_vars_handles_multilingual_array_value_without_throw(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{commerce_name} - {product_name}');
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true);
// resolveVars 결과에 다국어 array 가 들어가는 경로: 표현식이 직접 array 반환
// (예: 다국어 객체 리터럴 또는 evaluator 가 반환한 값)
app()->setLocale('ko');
$seoConfig = [
'page_type' => 'product',
'vars' => [
// 표현식이 다국어 객체를 직접 반환 — substituteVars 까지 array 전달
'product_name' => '{{product.data.name}}',
'commerce_name' => '$module_settings:basic_info.shop_name',
],
];
// product.data.name 자체를 다국어 array 로
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', [
'ko' => '한글몰', 'en' => 'EnglishMall',
]);
$context = ['product' => ['data' => ['name' => ['ko' => '에어맥스', 'en' => 'AirMax']]]];
// throw 없이 정상 반환 (현재 로케일 ko)
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
$this->assertSame('한글몰 - 에어맥스', $result['title']);
}
/**
* 회귀: 모듈 설정값이 다국어 JSON 배열인 경우에도 vars 해석이 throw 없이 정상 동작.
*
* meta.seo.vars 에서 `$module_settings:key` 접두사로 모듈 설정을 참조하는데,
* 운영자가 commerce_name(쇼핑몰 이름) 등을 MultilingualInput 으로 저장하면 array 가 됨.
*
* 이전 회귀: SeoMetaResolver.resolveVarExpression 의 `(string) g7_module_settings(...)`
* 가 array → "Array to string conversion" → render() throw → SPA fallback.
*/
public function test_resolve_vars_handles_multilingual_array_in_module_settings(): void
{
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{commerce_name} - {product_name}');
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product', true);
// 다국어 JSON array 형태로 저장된 모듈 설정
Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', [
'ko' => '한글몰',
'en' => 'EnglishMall',
]);
app()->setLocale('ko');
$seoConfig = [
'page_type' => 'product',
'vars' => [
'product_name' => "{{product.data.name ?? ''}}",
'commerce_name' => '$module_settings:basic_info.shop_name',
],
];
$context = ['product' => ['data' => ['name' => '에어맥스']]];
// throw 없이 정상 반환
$result = $this->resolver->resolve($seoConfig, $context, 'sirsoft-ecommerce', null, []);
$this->assertSame('한글몰 - 에어맥스', $result['title']);
}
/**
* 회귀: 레이아웃 og.title 이 직접 다국어 객체 리터럴 형식인 경우에도
* resolveOgData 가 throw 없이 현재 로케일 값으로 변환.
*/
public function test_resolve_handles_multilingual_array_in_layout_og_field(): void
{
app()->setLocale('ko');
$seoConfig = [
'og' => [
'type' => 'product',
'title' => ['ko' => '에어맥스', 'en' => 'AirMax'],
'description' => ['ko' => '운동화', 'en' => 'Sneakers'],
'image' => ['ko' => 'https://e.co/ko.jpg', 'en' => 'https://e.co/en.jpg'],
],
];
// throw 없이 정상 반환
$result = $this->resolver->resolve($seoConfig, [], null, null, []);
$this->assertSame('에어맥스', $result['og']['title']);
$this->assertSame('운동화', $result['og']['description']);
$this->assertSame('https://e.co/ko.jpg', $result['og']['image']);
}
/**
* 접미사 조립: 저장 시 TrimStrings 가 선행 공백을 제거하므로("| 그누보드7"),
* 제목이 있으면 공백을 복원해 잇고, 제목이 비면 매달린 구분자를 떼어낸다.
* (blade 는 title.titleSuffix 단순 연결 — 조립 규칙은 이 메서드가 SSoT)
*/
public function test_compose_title_suffix_restores_space_between_title_and_trimmed_suffix(): void
{
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', '| 그누보드7'));
}
public function test_compose_title_suffix_keeps_explicit_leading_space_as_is(): void
{
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ' | 그누보드7'));
}
public function test_compose_title_suffix_strips_dangling_separator_when_title_is_empty(): void
{
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', '| 그누보드7'));
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', ' - 그누보드7'));
}
public function test_compose_title_suffix_returns_empty_when_suffix_is_blank(): void
{
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ''));
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', ' '));
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', '| '));
}
}