'https://cdn/p.jpg', 'image_alt' => '베이직 오버핏 티셔츠']; } public function seoOgDefaultMeta(string $pageType): array { return [ 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => ['ko' => '상품 대표 이미지', 'en' => 'Product image']], 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => ['ko' => '상품 이름', 'en' => 'Product name']], ]; } public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array { return []; } public function seoTwitterDefaultMeta(string $pageType): array { return []; } public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array { return ['@type' => 'Product', 'name' => '베이직 오버핏 티셔츠', 'offers' => ['price' => '23200']]; } public function seoStructuredDataMeta(string $pageType): array { return [ 'name' => ['expr' => '{{product.data.name}}', 'label' => ['ko' => '상품 이름', 'en' => 'Product name']], 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => ['ko' => '판매가', 'en' => 'Selling price']], ]; } }; } private function service(?AbstractModule $module): SeoOgPreviewService { $moduleManager = $this->createMock(ModuleManagerInterface::class); $moduleManager->method('getModule')->willReturn($module); $pluginManager = $this->createMock(PluginManagerInterface::class); $pluginManager->method('getPlugin')->willReturn(null); return new SeoOgPreviewService( app(SeoMetaResolver::class), $moduleManager, $pluginManager, ); } /** og 배열에서 특정 key 행을 찾는다. */ private function ogRow(array $preview, string $key): ?array { foreach ($preview['og'] as $row) { if ($row['key'] === $key) { return $row; } } return null; } /** 자동값 cascade 가 일어나도록 extensions ∧ page_type 충족 + 레이아웃 og 비움. */ private function seoConfig(array $overrides = []): array { return array_merge([ 'page_type' => 'product', 'extensions' => [['type' => 'module', 'id' => 'sirsoft-ecommerce']], ], $overrides); } public function test_모듈_출처_og_행에_sourceExpr_와_라벨이_동반된다(): void { app()->setLocale('ko'); $preview = $this->service($this->fakeModule())->preview($this->seoConfig(), [], []); $imageRow = $this->ogRow($preview, 'image'); $this->assertNotNull($imageRow); $this->assertStringStartsWith('module:', $imageRow['source']); $this->assertSame('{{product.data.thumbnail_url}}', $imageRow['sourceExpr']); $this->assertSame('상품 대표 이미지', $imageRow['label'], '라벨은 현재 로케일(ko)로 해석'); $altRow = $this->ogRow($preview, 'image_alt'); $this->assertSame('{{product.data.name}}', $altRow['sourceExpr']); $this->assertSame('상품 이름', $altRow['label']); } public function test_라벨은_요청_로케일로_해석된다(): void { app()->setLocale('en'); $preview = $this->service($this->fakeModule())->preview($this->seoConfig(), [], [], null, 'en'); $imageRow = $this->ogRow($preview, 'image'); $this->assertSame('Product image', $imageRow['label']); } /** label 이 번역 키 문자열이면 __() 로 해석된다 — 언어팩 대응(추가 언어 자동). */ private function translationKeyLabelModule(): AbstractModule { return new class extends AbstractModule { public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array { return ['image' => 'https://cdn/p.jpg']; } public function seoOgDefaultMeta(string $pageType): array { // 인라인 ko/en 맵이 아니라 **번역 키 문자열** — 언어팩(ja 등)이 그 키를 번역하면 자동 대응. return ['image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'demo-pkg::seo.product_image']]; } }; } public function test_label_번역키_문자열은_언어팩으로_해석된다(): void { // 언어팩이 키를 번역한 상태를 모사 — 번역기에 키 등록(ja 로케일). app('translator')->addLines(['seo.product_image' => '商品画像'], 'ja', 'demo-pkg'); app()->setLocale('ja'); $preview = $this->service($this->translationKeyLabelModule())->preview($this->seoConfig(), [], [], null, 'ja'); $imageRow = $this->ogRow($preview, 'image'); // 번역 키가 그대로 노출되면(=__() 미해석) 실패. 언어팩 번역값으로 해석돼야 통과. $this->assertSame('商品画像', $imageRow['label'], 'label 번역 키는 __() 로 해석돼 언어팩에 대응해야 한다'); } public function test_label_번역키_미등록_로케일은_다른_언어로_폴백되지_원문키_노출_안함(): void { // ko/en 만 등록된 키를 미등록 로케일(ja)로 요청 — 번역기 폴백(기본 로케일)로 사람이 읽을 // 문자열이 나오되, raw 키(demo-pkg::seo.x)가 그대로 노출되지는 않아야 한다. app('translator')->addLines(['seo.product_image' => '상품 이미지'], 'ko', 'demo-pkg'); app('translator')->addLines(['seo.product_image' => 'Product image'], 'en', 'demo-pkg'); $preview = $this->service($this->translationKeyLabelModule())->preview($this->seoConfig(), [], [], null, 'ja'); $imageRow = $this->ogRow($preview, 'image'); $this->assertStringNotContainsString('demo-pkg::', $imageRow['label'], 'raw 번역 키가 노출되면 안 된다'); } public function test_레이아웃이_직접_채운_키엔_메타_미동반(): void { // 레이아웃이 og.image 를 직접 입력 → 출처 layout → 연결 칩 메타 미동반(자기 값이므로). $config = $this->seoConfig(['og' => ['image' => '{{custom.path}}']]); $preview = $this->service($this->fakeModule())->preview($config, [], []); $imageRow = $this->ogRow($preview, 'image'); $this->assertSame('layout', $imageRow['source']); $this->assertArrayNotHasKey('sourceExpr', $imageRow); $this->assertArrayNotHasKey('label', $imageRow); } public function test_메타_미선언_모듈은_평문_폴백_메타_미동반(): void { // seoOgDefaultMeta 가 빈 배열(부모 기본)인 모듈 → effectiveValue(평문)만, 연결 칩 메타 없음(하위호환). $plain = new class extends AbstractModule { public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array { return ['image' => 'https://cdn/p.jpg']; } }; $preview = $this->service($plain)->preview($this->seoConfig(), [], []); $imageRow = $this->ogRow($preview, 'image'); $this->assertNotNull($imageRow); $this->assertStringStartsWith('module:', $imageRow['source']); $this->assertSame('https://cdn/p.jpg', $imageRow['effectiveValue'], '평문 폴백 유지'); $this->assertArrayNotHasKey('sourceExpr', $imageRow); } public function test_structured_autoMeta_가_점경로_키별_데이터경로를_동반한다(): void { app()->setLocale('ko'); $preview = $this->service($this->fakeModule())->preview($this->seoConfig(), [], []); $autoMeta = $preview['structured']['autoMeta']; $this->assertArrayHasKey('name', $autoMeta); $this->assertSame('{{product.data.name}}', $autoMeta['name']['expr']); $this->assertSame('상품 이름', $autoMeta['name']['label']); $this->assertArrayHasKey('offers.price', $autoMeta); $this->assertSame('{{product.data.selling_price}}', $autoMeta['offers.price']['expr']); // 자동 블록 자체는 종전대로 평문(하위호환). $this->assertSame('베이직 오버핏 티셔츠', $preview['structured']['autoBlock']['name']); } public function test_레이아웃_구조화_선언이_있으면_autoMeta_는_여전히_모듈_기준(): void { // 레이아웃이 structured_data 를 통 override 해도 autoBlock/autoMeta(모듈 자동)는 OFF 미리보기용으로 유지. $config = $this->seoConfig(['structured_data' => ['@type' => 'WebPage']]); $preview = $this->service($this->fakeModule())->preview($config, [], []); $this->assertTrue($preview['structured']['hasLayoutBlock']); $this->assertArrayHasKey('name', $preview['structured']['autoMeta']); } }