routeResolver = Mockery::mock(TemplateRouteResolver::class); $this->layoutService = Mockery::mock(LayoutService::class); $this->templateService = Mockery::mock(TemplateService::class); $this->dataSourceResolver = Mockery::mock(DataSourceResolver::class); $this->metaResolver = Mockery::mock(SeoMetaResolver::class); $this->htmlMapper = Mockery::mock(ComponentHtmlMapper::class); $this->evaluator = Mockery::mock(ExpressionEvaluator::class); $this->seoConfigMerger = Mockery::mock(SeoConfigMerger::class); $this->settingsService = Mockery::mock(SettingsService::class); $this->pluginSettingsService = Mockery::mock(PluginSettingsService::class); $this->moduleManager = Mockery::mock(ModuleManagerInterface::class); $this->pluginManager = Mockery::mock(PluginManagerInterface::class); // 기본 SettingsService / PluginSettingsService mock $this->settingsService->shouldReceive('getFrontendSettings') ->andReturn([]) ->byDefault(); $this->pluginSettingsService->shouldReceive('getAllActiveSettings') ->andReturn([]) ->byDefault(); // 기본 metaResolver mock — resolveLocalizedValue 는 다국어 처리 흉내 (array → 첫 키 값) $this->metaResolver->shouldReceive('resolveLocalizedValue') ->andReturnUsing(function ($value) { if (is_string($value)) { return $value; } if (is_array($value)) { $locale = app()->getLocale(); return (string) ($value[$locale] ?? reset($value) ?? ''); } return (string) ($value ?? ''); }) ->byDefault(); // 기본 SeoConfigMerger mock (병합된 config 반환) $this->seoConfigMerger->shouldReceive('getMergedConfig') ->andReturn([]) ->byDefault(); // 기본 TemplateService mock (번역 로드 항상 성공) $this->templateService->shouldReceive('getLanguageDataWithModules') ->andReturn(['success' => true, 'data' => [], 'error' => null]) ->byDefault(); // 기본 evaluator mock (setTranslations, getPipeRegistry 허용) $this->evaluator->shouldReceive('setTranslations') ->byDefault(); $pipeRegistry = Mockery::mock(PipeRegistry::class); $pipeRegistry->shouldReceive('setLocale')->byDefault(); $this->evaluator->shouldReceive('getPipeRegistry') ->andReturn($pipeRegistry) ->byDefault(); $this->evaluator->shouldReceive('setSeoOverrides') ->byDefault(); // 기본 htmlMapper mock (seo-config.json 로드 시 호출되는 설정 메서드 허용) $this->htmlMapper->shouldReceive('setComponentMap')->byDefault(); $this->htmlMapper->shouldReceive('setRenderModes')->byDefault(); $this->htmlMapper->shouldReceive('setSelfClosing')->byDefault(); $this->htmlMapper->shouldReceive('setTextProps')->byDefault(); $this->htmlMapper->shouldReceive('setAttrMap')->byDefault(); $this->htmlMapper->shouldReceive('setAllowedAttrs')->byDefault(); // 기본 htmlMapper mock (setGlobalResolver 허용 — navigate 링크 생성용) $this->htmlMapper->shouldReceive('setGlobalResolver') ->byDefault(); // 기본 htmlMapper mock (setSeoVars 허용 — format 모드 변수 주입용) $this->htmlMapper->shouldReceive('setSeoVars') ->byDefault(); $this->renderer = new SeoRenderer( $this->routeResolver, $this->layoutService, $this->templateService, $this->dataSourceResolver, $this->metaResolver, $this->htmlMapper, $this->evaluator, $this->seoConfigMerger, $this->settingsService, $this->pluginSettingsService, $this->moduleManager, $this->pluginManager, ); } /** * 정상적인 상품 상세 페이지 렌더링 시 title, meta, body를 포함한 HTML을 반환합니다. */ public function test_render_product_detail_returns_complete_html(): void { $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [['component' => 'Div', 'props' => ['text' => '상품 상세']]], toggleSetting: '$module_settings:seo.seo_product_detail', ); $this->layoutService ->shouldReceive('getLayout') ->with('sirsoft-user_basic', 'shop/show') ->once() ->andReturn($mergedLayout); // 모듈 SEO 설정 활성화 config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver ->shouldReceive('resolve') ->once() ->andReturn(['product' => ['data' => ['name' => '에어맥스', 'price' => 129000]]]); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult( title: '에어맥스', description: '에어맥스 상품 상세', )); $this->htmlMapper ->shouldReceive('render') ->once() ->andReturn('
상품 상세
'); $expectedHtml = '에어맥스
상품 상세
'; $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn($expectedHtml); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return $data['title'] === '에어맥스' && $data['description'] === '에어맥스 상품 상세' && $data['bodyHtml'] === '
상품 상세
'; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); $this->assertStringContainsString('에어맥스', $result); } /** * meta.seo.enabled=false일 때 null을 반환합니다. */ public function test_render_returns_null_when_seo_disabled(): void { $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: false); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); $result = $this->renderer->render($request); $this->assertNull($result); } /** * 모듈 SEO가 비활성화된 경우(toggle_setting → false) null을 반환합니다. */ public function test_render_returns_null_when_module_seo_disabled(): void { $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, toggleSetting: '$module_settings:seo.seo_product_detail', ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 모듈 SEO 비활성화 config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false); $result = $this->renderer->render($request); $this->assertNull($result); } /** * DataSource 실패 시 fallback 데이터로 렌더링합니다. */ public function test_render_with_datasource_failure_renders_with_fallback(): void { $request = Request::create('/about'); $this->setupRouteResolver('/about', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'about', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['site_info'], components: [['component' => 'Div']], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // DataSource 빈 결과 반환 (실패 시 빈 배열) $this->dataSourceResolver ->shouldReceive('resolve') ->once() ->andReturn([]); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '회사 소개')); $this->htmlMapper ->shouldReceive('render') ->once() ->andReturn('
소개 페이지
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('회사 소개'); View::shouldReceive('make') ->with('seo', Mockery::type('array')) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * URL에 매칭되는 레이아웃이 없으면 null을 반환합니다. */ public function test_render_returns_null_when_no_layout_for_url(): void { $request = Request::create('/unknown-page'); $this->routeResolver ->shouldReceive('resolve') ->with('/unknown-page') ->once() ->andReturn(null); $result = $this->renderer->render($request); $this->assertNull($result); } /** * structured_data가 있으면 JSON-LD가 포함됩니다. */ public function test_render_includes_json_ld_when_structured_data_present(): void { $request = Request::create('/products/456'); $this->setupRouteResolver('/products/456', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '456'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); $this->dataSourceResolver ->shouldReceive('resolve') ->once() ->andReturn(['product' => ['data' => ['name' => '신발']]]); $jsonLd = ''; $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '신발', jsonLd: $jsonLd)); $this->htmlMapper ->shouldReceive('render') ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''.$jsonLd.''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) use ($jsonLd) { return $data['jsonLd'] === $jsonLd; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); $this->assertStringContainsString('application/ld+json', $result); } /** * OG 태그가 뷰 데이터에 포함됩니다. */ public function test_render_includes_og_tags(): void { $request = Request::create('/products/789'); $this->setupRouteResolver('/products/789', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '789'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); $ogTags = ''."\n".''; $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '운동화', ogTags: $ogTags)); $this->htmlMapper ->shouldReceive('render') ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''.$ogTags.''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return str_contains($data['ogTags'], 'og:title'); })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); $this->assertStringContainsString('og:title', $result); } /** * 코어 Laravel 에셋 빌드 산출물(app.css)은 뷰 데이터에 주입되지 않습니다. * * seo.blade.php 가 참조하던 코어 app.css 는 실제 사용자 화면 * (app.blade.php·admin.blade.php)에서 쓰이지 않는 Laravel 기본 빌드 파일이며, * Vite manifest 구조상 해시 파일로만 생성되어 고정 경로 `/build/assets/app.css` * 는 404 였다. 봇 렌더링에 필요한 스타일은 템플릿 CSS($stylesheets)가 담당하므로 * 코어 app.css 참조를 제거했다. 이 테스트는 cssPath 잔재가 재유입되지 않도록 가드한다. * (공개#64 — 봇 SEO HTML CSS 404 제거) */ public function test_render_does_not_inject_core_app_css_path(): void { $request = Request::create('/about'); $this->setupRouteResolver('/about', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'about', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '소개')); $this->htmlMapper ->shouldReceive('render') ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { // cssPath 키가 더 이상 뷰 데이터에 존재하지 않아야 한다. return ! array_key_exists('cssPath', $data); })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * seo.data_sources 필터링: 지정된 데이터소스만 호출됩니다. */ public function test_render_filters_data_sources_by_seo_config(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $allDataSources = [ ['id' => 'product', 'endpoint' => '/api/products/{id}'], ['id' => 'reviews', 'endpoint' => '/api/products/{id}/reviews'], ['id' => 'recommendations', 'endpoint' => '/api/recommendations'], ]; $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], ); $mergedLayout['data_sources'] = $allDataSources; $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // seo.data_sources에 'product'만 지정 → resolve에 전달되는 seoDataSourceIds 확인 // 6번째 인자는 엔드포인트 표현식 평가용 사전 컨텍스트 (route/query/_global) $this->dataSourceResolver ->shouldReceive('resolve') ->with( $allDataSources, ['product'], ['id' => '1'], Mockery::any(), Mockery::any(), Mockery::on(fn ($preContext) => isset($preContext['_global']) && isset($preContext['route'])) ) ->once() ->andReturn(['product' => ['data' => ['name' => '상품']]]); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품')); $this->htmlMapper ->shouldReceive('render') ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make') ->with('seo', Mockery::type('array')) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * 레이아웃 로드 실패 시 null을 반환합니다. */ public function test_render_returns_null_when_layout_load_fails(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $this->layoutService ->shouldReceive('getLayout') ->once() ->andThrow(new \RuntimeException('Layout not found')); $result = $this->renderer->render($request); $this->assertNull($result); } // ======================================================== // seo-config.json 로드 + setComponentMap/setRenderModes/setSelfClosing 검증 // ======================================================== /** * seo-config.json 존재 시 setComponentMap이 호출됩니다. */ public function test_seo_config_loaded_and_component_map_set(): void { $this->runSeoConfigTest( config: [ 'component_map' => ['Div' => ['tag' => 'div'], 'Span' => ['tag' => 'span']], ], expectations: function () { $this->htmlMapper ->shouldReceive('setComponentMap') ->with(Mockery::on(fn ($map) => isset($map['Div']) && $map['Div']['tag'] === 'div')) ->once(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); }, ); } /** * seo-config.json 존재 시 setRenderModes가 호출됩니다. */ public function test_seo_config_loaded_and_render_modes_set(): void { $this->runSeoConfigTest( config: [ 'render_modes' => [ 'image_gallery' => ['type' => 'iterate', 'source' => '$props_source', 'item_tag' => 'img'], ], ], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper ->shouldReceive('setRenderModes') ->with(Mockery::on(fn ($modes) => isset($modes['image_gallery']) && $modes['image_gallery']['type'] === 'iterate')) ->once(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); }, ); } /** * seo-config.json 존재 시 setSelfClosing이 호출됩니다. */ public function test_seo_config_loaded_and_self_closing_set(): void { $this->runSeoConfigTest( config: [ 'self_closing' => ['img', 'input', 'hr', 'br'], ], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper ->shouldReceive('setSelfClosing') ->with(['img', 'input', 'hr', 'br']) ->once(); }, ); } /** * seo-config.json의 stylesheets가 View에 전달됩니다. */ public function test_stylesheets_passed_to_view(): void { $stylesheets = [ 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css', '/custom/style.css', ]; $this->runSeoConfigTest( config: [ 'stylesheets' => $stylesheets, ], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); }, viewAssert: function ($data) use ($stylesheets) { return $data['stylesheets'] === $stylesheets; }, ); } /** * seo-config.json 미존재 시 set* 메서드가 호출되지 않습니다. */ public function test_missing_seo_config_no_calls(): void { // 존재하지 않는 templateIdentifier 사용 (파일 생성 안함) $request = Request::create('/about'); $this->setupRouteResolver('/about', [ 'templateIdentifier' => 'nonexistent-template', 'layoutName' => 'about', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: 'Test')); $this->htmlMapper->shouldReceive('render')->andReturn(''); // set* 메서드가 호출되지 않아야 함 $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Test'); View::shouldReceive('make') ->with('seo', Mockery::on(fn ($data) => ($data['stylesheets'] ?? []) === [])) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * template.json의 assets.css가 stylesheets에 자동 포함됩니다. */ public function test_template_css_auto_included_in_stylesheets(): void { $templateId = 'test-template-css-'.uniqid(); $configDir = base_path("templates/{$templateId}"); if (! is_dir($configDir)) { mkdir($configDir, 0755, true); } // SeoConfigMerger mock으로 병합된 config 반환 (Font Awesome 포함) $this->seoConfigMerger->shouldReceive('getMergedConfig') ->with($templateId) ->andReturn([ 'stylesheets' => ['https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'], ]); // template.json 생성 (CSS 에셋 포함) file_put_contents("{$configDir}/template.json", json_encode([ 'identifier' => $templateId, 'assets' => [ 'css' => ['dist/css/components.css'], 'js' => [], ], ], JSON_PRETTY_PRINT)); // 선언한 산출물이 실재해야 링크된다 — 없는 경로의 는 봇 화면에서만 404 가 // 되고 어디에도 흔적이 남지 않으므로 렌더러가 실재 파일만 싣는다. mkdir("{$configDir}/dist/css", 0755, true); file_put_contents("{$configDir}/dist/css/components.css", ''); // 자산 URL 모드를 고정 — 운영 설정(general.asset_url_mode)에 따라 // 확장자 유지/제거 두 형태가 나오므로 검증 대상 형태를 명시한다. AssetUrl::forceMode(AssetUrl::MODE_EXTENSION); try { $request = Request::create('/test-css'); $this->setupRouteResolver('/test-css', [ 'templateIdentifier' => $templateId, 'layoutName' => 'test-css', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: 'CSS Test')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('CSS Test'); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) use ($templateId) { $stylesheets = $data['stylesheets'] ?? []; // 템플릿 CSS URL이 포함되어야 함 $hasTemplateCss = false; $hasFontAwesome = false; foreach ($stylesheets as $url) { if (str_contains($url, "/api/templates/assets/{$templateId}/css/components.css")) { $hasTemplateCss = true; } if (str_contains($url, 'font-awesome')) { $hasFontAwesome = true; } } return $hasTemplateCss && $hasFontAwesome; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } finally { AssetUrl::forceMode(null); @unlink("{$configDir}/dist/css/components.css"); @rmdir("{$configDir}/dist/css"); @rmdir("{$configDir}/dist"); @unlink("{$configDir}/template.json"); @rmdir($configDir); } } /** * seo-config.json이 빈 객체일 때 set* 메서드가 호출되지 않습니다. */ public function test_empty_seo_config_no_calls(): void { $this->runSeoConfigTest( config: [], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); }, ); } /** * seo-config.json 테스트를 위한 공통 헬퍼 메서드. * SeoConfigMerger mock을 설정하고 SeoRenderer.render()를 실행합니다. * * @param array $config 병합된 SEO config 내용 * @param \Closure $expectations htmlMapper mock 기대값 설정 * @param \Closure|null $viewAssert View::make 데이터 검증 (null이면 기본) */ private function runSeoConfigTest(array $config, \Closure $expectations, ?\Closure $viewAssert = null): void { $templateId = 'test-seo-config-'.uniqid(); // SeoConfigMerger mock으로 병합된 config 반환 $this->seoConfigMerger->shouldReceive('getMergedConfig') ->with($templateId) ->andReturn($config); $request = Request::create('/test-page'); $this->setupRouteResolver('/test-page', [ 'templateIdentifier' => $templateId, 'layoutName' => 'test-page', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: 'Test')); $this->htmlMapper->shouldReceive('render')->andReturn(''); // mock 기대값 설정 $expectations(); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Test'); if ($viewAssert) { View::shouldReceive('make') ->with('seo', Mockery::on($viewAssert)) ->once() ->andReturn($viewMock); } else { View::shouldReceive('make') ->with('seo', Mockery::type('array')) ->once() ->andReturn($viewMock); } $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * routeResolver에 URL을 전달하여 라우트 정보를 반환하도록 설정합니다. * * @param string $url 요청 URL * @param array|null $routeInfo 반환할 라우트 정보 */ private function setupRouteResolver(string $url, ?array $routeInfo): void { $this->routeResolver ->shouldReceive('resolve') ->with($url) ->once() ->andReturn($routeInfo); } /** * 테스트용 병합된 레이아웃을 빌드합니다. * * @param bool $seoEnabled SEO 활성화 여부 * @param array $seoDataSources SEO 데이터소스 ID 목록 * @param array $components 컴포넌트 배열 * @return array 병합된 레이아웃 배열 */ private function buildMergedLayout( bool $seoEnabled = true, array $seoDataSources = [], array $components = [], ?string $toggleSetting = null, ?string $pageType = null, ?array $vars = null, ?array $initActions = null, ?array $structuredData = null, ?array $computed = null, ?array $extensions = null, ?array $state = null, ?array $initLocal = null, ?array $initGlobal = null, ): array { $seo = [ 'enabled' => $seoEnabled, 'data_sources' => $seoDataSources, ]; if ($extensions !== null) { $seo['extensions'] = $extensions; } if ($toggleSetting !== null) { $seo['toggle_setting'] = $toggleSetting; } if ($pageType !== null) { $seo['page_type'] = $pageType; } if ($vars !== null) { $seo['vars'] = $vars; } if ($structuredData !== null) { $seo['structured_data'] = $structuredData; } $layout = [ 'meta' => [ 'seo' => $seo, ], 'data_sources' => [], 'components' => $components, ]; if ($initActions !== null) { // LayoutService 병합 결과는 camelCase (initActions) 키를 사용 $layout['initActions'] = $initActions; } if ($computed !== null) { $layout['computed'] = $computed; } if ($state !== null) { $layout['state'] = $state; } if ($initLocal !== null) { $layout['initLocal'] = $initLocal; } if ($initGlobal !== null) { $layout['initGlobal'] = $initGlobal; } return $layout; } /** * 테스트용 메타 결과 배열을 빌드합니다. * * @param string $title 타이틀 * @param string $description 설명 * @param string $ogTags OG 태그 HTML * @param string $jsonLd JSON-LD HTML * @return array 메타 결과 배열 */ private function buildMetaResult( string $title = '', string $description = '', string $ogTags = '', string $jsonLd = '', array $og = [], array $twitter = [], ?array $structuredData = null, string $twitterTags = '', ): array { return [ 'title' => $title, 'titleSuffix' => '', 'description' => $description, 'keywords' => '', 'og' => $og, 'twitter' => $twitter, 'structured_data' => $structuredData, 'extraMetaTags' => [], 'ogTags' => $ogTags, 'twitterTags' => $twitterTags, 'jsonLd' => $jsonLd, 'googleAnalyticsId' => '', 'googleVerification' => '', 'naverVerification' => '', ]; } // ========================================================================= // toggle_setting + config 전달 테스트 (8개) // ========================================================================= /** * toggle_setting이 모듈 설정 true이면 렌더링이 진행됩니다. */ public function test_toggle_setting_module_enabled(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], toggleSetting: '$module_settings:seo.seo_product_detail', ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); // 모듈 SEO 활성화 config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([]); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '상품')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * toggle_setting 미선언 시 무조건 렌더링이 진행됩니다. */ public function test_toggle_setting_not_declared_always_renders(): void { $request = Request::create('/about'); $this->setupRouteResolver('/about', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'about', 'routeParams' => [], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); // toggle_setting 미선언 $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '소개')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('소개'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); // toggle_setting 없으므로 모듈 SEO 무조건 활성 → 렌더링 $this->assertNotNull($result); } /** * toggle_setting이 $core_settings: 접두사일 때 코어 설정으로 판단합니다. */ public function test_toggle_setting_core_settings(): void { $request = Request::create('/search'); $this->setupRouteResolver('/search', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'search/index', 'routeParams' => [], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [], toggleSetting: '$core_settings:seo.seo_search', ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); // 코어 SEO 비활성화 config()->set('g7_settings.core.seo.seo_search', false); $result = $this->renderer->render($request); $this->assertNull($result); } /** * seo-config.json의 text_props가 setTextProps로 전달됩니다. */ public function test_text_props_passed_to_mapper(): void { $this->runSeoConfigTest( config: [ 'text_props' => ['text', 'label', 'value', 'title'], ], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $this->htmlMapper ->shouldReceive('setTextProps') ->with(['text', 'label', 'value', 'title']) ->once(); $this->htmlMapper->shouldReceive('setAttrMap')->never(); $this->htmlMapper->shouldReceive('setAllowedAttrs')->never(); }, ); } /** * seo-config.json의 attr_map이 setAttrMap으로 전달됩니다. */ public function test_attr_map_passed_to_mapper(): void { $this->runSeoConfigTest( config: [ 'attr_map' => ['className' => 'class', 'htmlFor' => 'for'], ], expectations: function () { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $this->htmlMapper->shouldReceive('setTextProps')->never(); $this->htmlMapper ->shouldReceive('setAttrMap') ->with(['className' => 'class', 'htmlFor' => 'for']) ->once(); $this->htmlMapper->shouldReceive('setAllowedAttrs')->never(); }, ); } /** * seo-config.json의 allowed_attrs가 setAllowedAttrs로 전달됩니다. */ public function test_allowed_attrs_passed_to_mapper(): void { $allowedAttrs = ['class', 'id', 'href', 'src', 'alt']; $this->runSeoConfigTest( config: [ 'allowed_attrs' => $allowedAttrs, ], expectations: function () use ($allowedAttrs) { $this->htmlMapper->shouldReceive('setComponentMap')->never(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $this->htmlMapper->shouldReceive('setTextProps')->never(); $this->htmlMapper->shouldReceive('setAttrMap')->never(); $this->htmlMapper ->shouldReceive('setAllowedAttrs') ->with($allowedAttrs) ->once(); }, ); } /** * seo-config.json에 text_props/attr_map/allowed_attrs가 없으면 set 메서드가 호출되지 않습니다. */ public function test_missing_new_config_keys_no_calls(): void { $this->runSeoConfigTest( config: [ 'component_map' => ['Div' => ['tag' => 'div']], ], expectations: function () { $this->htmlMapper ->shouldReceive('setComponentMap') ->once(); $this->htmlMapper->shouldReceive('setRenderModes')->never(); $this->htmlMapper->shouldReceive('setSelfClosing')->never(); $this->htmlMapper->shouldReceive('setTextProps')->never(); $this->htmlMapper->shouldReceive('setAttrMap')->never(); $this->htmlMapper->shouldReceive('setAllowedAttrs')->never(); }, ); } // ======================================================== // 다국어 SEO: hreflang + canonical 테스트 // ======================================================== /** * 기본 로케일(ko)에서 canonical URL에 ?locale 파라미터가 없는지 검증 */ public function test_canonical_url_no_locale_param_for_default_locale(): void { config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]); app()->setLocale('ko'); $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '상품')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { // canonical에 ?locale 없어야 함 return ! str_contains($data['canonicalUrl'], '?locale') && str_contains($data['canonicalUrl'], '/products/123'); })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * 비기본 로케일(en)에서 canonical URL에 ?locale=en이 포함되는지 검증 */ public function test_canonical_url_includes_locale_param_for_non_default(): void { config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]); app()->setLocale('en'); $request = Request::create('/products/123'); // SeoMiddleware가 setLocale 전에 저장하는 기본 로케일을 시뮬레이션 $request->attributes->set('seo_default_locale', 'ko'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: 'Product')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Product'); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return str_contains($data['canonicalUrl'], '?locale=en'); })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * hreflang 태그에 모든 supported_locales가 포함되는지 검증 */ public function test_hreflang_tags_include_all_supported_locales(): void { config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]); app()->setLocale('ko'); $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '상품')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { $tags = $data['hreflangTags'] ?? ''; // ko hreflang (기본 = 파라미터 없음) $hasKo = str_contains($tags, 'hreflang="ko"') && str_contains($tags, '/products/123"'); // en hreflang (?locale=en 포함) $hasEn = str_contains($tags, 'hreflang="en"') && str_contains($tags, '?locale=en'); // x-default (기본 로케일 URL) $hasDefault = str_contains($tags, 'hreflang="x-default"'); return $hasKo && $hasEn && $hasDefault; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * 로케일 1개뿐이면 hreflang 태그가 빈 문자열인지 검증 */ public function test_hreflang_tags_empty_when_single_locale(): void { config(['app.locale' => 'ko', 'app.supported_locales' => ['ko']]); app()->setLocale('ko'); $request = Request::create('/about'); $this->setupRouteResolver('/about', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'about', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '소개')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('소개'); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return ($data['hreflangTags'] ?? '') === ''; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * vars가 레이아웃 seoConfig에 포함되어 metaResolver에 전달됩니다. */ public function test_vars_passed_to_meta_resolver(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $vars = [ 'product_name' => "{{product.data.name ?? ''}}", 'commerce_name' => '$module_settings:basic_info.shop_name', ]; $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], toggleSetting: '$module_settings:seo.seo_product_detail', pageType: 'product', vars: $vars, ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([]); // resolveSeoVars에서 표현식 평가를 위해 evaluator.evaluate 허용 $this->evaluator->shouldReceive('evaluate') ->andReturn('') ->byDefault(); // setSeoVars가 해석된 vars로 호출되는지 검증 config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트 쇼핑몰'); $this->htmlMapper ->shouldReceive('setSeoVars') ->with(Mockery::on(function ($resolved) { return isset($resolved['commerce_name']) && $resolved['commerce_name'] === '테스트 쇼핑몰'; })) ->once(); // metaResolver에 vars가 포함된 seoConfig가 전달되는지 확인 $this->metaResolver ->shouldReceive('resolve') ->with( Mockery::on(function ($seoConfig) use ($vars) { return ($seoConfig['vars'] ?? []) === $vars && ($seoConfig['page_type'] ?? '') === 'product'; }), Mockery::type('array'), 'sirsoft-ecommerce', null, Mockery::type('array') ) ->once() ->andReturn($this->buildMetaResult(title: '상품')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } // ========================================================================= // meta.seo.vars → ComponentHtmlMapper.setSeoVars() 전달 테스트 // ========================================================================= /** * meta.seo.vars에 $core_settings: 변수가 선언되면 해석 후 setSeoVars()로 전달됩니다. */ public function test_seo_vars_core_settings_passed_to_html_mapper(): void { $request = Request::create('/products'); $this->setupRouteResolver('/products', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Header', 'props' => []]], vars: [ 'site_name' => '$core_settings:general.site_name', ], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 코어 설정에서 사이트명 반환 config()->set('g7_settings.core.general.site_name', 'My Store'); // setSeoVars에 해석된 vars가 전달되는지 검증 $this->htmlMapper ->shouldReceive('setSeoVars') ->with(Mockery::on(function ($vars) { return isset($vars['site_name']) && $vars['site_name'] === 'My Store'; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Shop')); $this->htmlMapper->shouldReceive('render')->andReturn('
My Store
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Shop'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * meta.seo.vars에 $module_settings: 변수가 선언되면 해석 후 setSeoVars()로 전달됩니다. */ public function test_seo_vars_module_settings_passed_to_html_mapper(): void { $request = Request::create('/products'); $this->setupRouteResolver('/products', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Header', 'props' => []]], vars: [ 'commerce_name' => '$module_settings:basic_info.shop_name', ], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 모듈 설정에서 상점명 반환 config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '그누보드7 쇼핑몰'); // setSeoVars에 해석된 vars가 전달되는지 검증 $this->htmlMapper ->shouldReceive('setSeoVars') ->with(Mockery::on(function ($vars) { return isset($vars['commerce_name']) && $vars['commerce_name'] === '그누보드7 쇼핑몰'; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Shop')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Shop'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * meta.seo.vars가 없으면 setSeoVars()가 호출되지 않습니다. */ public function test_no_seo_vars_does_not_call_set_seo_vars(): void { $request = Request::create('/products'); $this->setupRouteResolver('/products', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Header', 'props' => []]], // vars 미설정 ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // setSeoVars가 호출되지 않아야 함 $this->htmlMapper ->shouldReceive('setSeoVars') ->never(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Shop')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Shop'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } // ========================================================================= // 플러그인 SEO 지원 테스트 (5개) // ========================================================================= /** * 플러그인 toggle_setting이 false이면 null을 반환합니다. */ public function test_render_returns_null_when_plugin_seo_disabled(): void { $request = Request::create('/payment/checkout'); $this->setupRouteResolver('/payment/checkout', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'checkout', 'routeParams' => [], 'moduleIdentifier' => null, 'pluginIdentifier' => 'sirsoft-payment', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, toggleSetting: '$plugin_settings:seo.enabled', ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 플러그인 SEO 비활성화 config()->set('g7_settings.plugins.sirsoft-payment.seo.enabled', false); $result = $this->renderer->render($request); $this->assertNull($result); } /** * 플러그인 toggle_setting이 true이면 렌더링이 진행됩니다. */ public function test_render_plugin_seo_enabled_with_toggle_setting(): void { $request = Request::create('/payment/checkout'); $this->setupRouteResolver('/payment/checkout', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'checkout', 'routeParams' => [], 'moduleIdentifier' => null, 'pluginIdentifier' => 'sirsoft-payment', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Div', 'props' => ['text' => '결제']]], toggleSetting: '$plugin_settings:seo.enabled', ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 플러그인 SEO 활성화 config()->set('g7_settings.plugins.sirsoft-payment.seo.enabled', true); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '결제')); $this->htmlMapper->shouldReceive('render')->andReturn('
결제
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('결제'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * $plugin_settings: 접두사 변수가 해석되어 setSeoVars()로 전달됩니다. */ public function test_resolve_seo_vars_with_plugin_settings_prefix(): void { $request = Request::create('/payment/info'); $this->setupRouteResolver('/payment/info', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'payment/info', 'routeParams' => [], 'moduleIdentifier' => null, 'pluginIdentifier' => 'sirsoft-payment', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Header', 'props' => []]], vars: [ 'payment_name' => '$plugin_settings:basic.payment_name', ], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 플러그인 설정값 config()->set('g7_settings.plugins.sirsoft-payment.basic.payment_name', '간편결제'); // setSeoVars에 해석된 vars가 전달되는지 검증 $this->htmlMapper ->shouldReceive('setSeoVars') ->with(Mockery::on(function ($vars) { return isset($vars['payment_name']) && $vars['payment_name'] === '간편결제'; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Payment')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Payment'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * globalResolver가 플러그인 설정 패턴(기본값 포함)을 해석합니다. */ public function test_global_resolver_handles_plugin_settings_with_default(): void { $request = Request::create('/payment/info'); $this->setupRouteResolver('/payment/info', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'payment/info', 'routeParams' => [], 'moduleIdentifier' => null, 'pluginIdentifier' => 'sirsoft-payment', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Div', 'props' => []]], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 플러그인 설정 미존재 → 기본값 'payment' 사용 config()->set('g7_settings.plugins.sirsoft-payment.basic_info.route_path', null); // setGlobalResolver 호출 시 콜백 캡처 $capturedResolver = null; $this->htmlMapper ->shouldReceive('setGlobalResolver') ->with(Mockery::on(function ($callback) use (&$capturedResolver) { $capturedResolver = $callback; return true; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Payment')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Payment'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); // 캡처된 globalResolver로 플러그인 패턴 테스트 $this->assertNotNull($capturedResolver); $result = $capturedResolver("_global.plugins?.['sirsoft-payment']?.basic_info?.route_path ?? 'payment'"); $this->assertEquals('payment', $result); } /** * globalResolver가 플러그인 설정 패턴(기본값 없음)을 해석합니다. */ public function test_global_resolver_handles_plugin_settings_without_default(): void { $request = Request::create('/payment/info'); $this->setupRouteResolver('/payment/info', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'payment/info', 'routeParams' => [], 'moduleIdentifier' => null, 'pluginIdentifier' => 'sirsoft-payment', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Div', 'props' => []]], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 플러그인 설정 존재 config()->set('g7_settings.plugins.sirsoft-payment.basic_info.name', '카카오페이'); // setGlobalResolver 호출 시 콜백 캡처 $capturedResolver = null; $this->htmlMapper ->shouldReceive('setGlobalResolver') ->with(Mockery::on(function ($callback) use (&$capturedResolver) { $capturedResolver = $callback; return true; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: 'Payment')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('Payment'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); // 캡처된 globalResolver로 플러그인 패턴 테스트 (기본값 없음) $this->assertNotNull($capturedResolver); $result = $capturedResolver("_global.plugins?.['sirsoft-payment']?.basic_info?.name"); $this->assertEquals('카카오페이', $result); } // ========================================================================= // 명시적 모듈 ID ($module_settings:MODULE_ID:key) 테스트 // ========================================================================= /** * 명시적 모듈 ID가 toggle_setting에서 올바르게 해석됩니다. * 템플릿 레벨 레이아웃(moduleIdentifier=null)에서도 모듈 설정을 참조합니다. */ public function test_toggle_setting_explicit_module_id_when_module_identifier_null(): void { $request = Request::create('/shop'); $this->setupRouteResolver('/shop', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, // 템플릿 레벨 레이아웃 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Div', 'props' => []]], toggleSetting: '$module_settings:sirsoft-ecommerce:seo.seo_index', ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 명시적 모듈 ID로 설정 조회 — 활성화됨 config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_index', true); $this->dataSourceResolver ->shouldReceive('resolve') ->andReturn([]); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '쇼핑몰')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('쇼핑몰'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * 명시적 모듈 ID toggle_setting이 false면 null을 반환합니다. */ public function test_toggle_setting_explicit_module_id_disabled_returns_null(): void { $request = Request::create('/shop'); $this->setupRouteResolver('/shop', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [], toggleSetting: '$module_settings:sirsoft-ecommerce:seo.seo_index', ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // 명시적 모듈 ID로 설정 조회 — 비활성화 config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_index', false); $result = $this->renderer->render($request); $this->assertNull($result); } /** * 명시적 모듈 ID가 resolveSeoVars에서 올바르게 해석됩니다. */ public function test_resolve_seo_vars_explicit_module_id(): void { $request = Request::create('/shop'); $this->setupRouteResolver('/shop', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], components: [['component' => 'Div', 'props' => []]], vars: [ 'shopBase' => '$module_settings:sirsoft-ecommerce:basic_info.route_path', 'commerce_name' => '$module_settings:sirsoft-ecommerce:basic_info.shop_name', ], ); $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', '/shop'); config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트몰'); // setSeoVars에 명시적 모듈 ID로 해석된 값이 전달되는지 검증 $this->htmlMapper ->shouldReceive('setSeoVars') ->with(Mockery::on(function ($vars) { return isset($vars['shopBase']) && $vars['shopBase'] === '/shop' && isset($vars['commerce_name']) && $vars['commerce_name'] === '테스트몰'; })) ->once(); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '쇼핑몰')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('쇼핑몰'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } // ========================================================================= // _global 컨텍스트 주입 테스트 // ========================================================================= /** * render()에서 _global.settings에 프론트엔드 설정이 주입됩니다. */ public function test_render_injects_global_settings_into_context(): void { $request = Request::create('/shop/products'); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $frontendSettings = [ 'general' => ['site_name' => 'My Store', 'site_logo_url' => '/logo.png'], 'seo' => ['meta_title_suffix' => 'My Store'], ]; // SettingsService가 프론트엔드 설정을 반환 $this->settingsService->shouldReceive('getFrontendSettings') ->once() ->andReturn($frontendSettings); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['products'], components: [['component' => 'Div', 'props' => ['text' => '상품']]], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['products' => ['data' => []]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품목록')); // htmlMapper.render에서 context의 _global.settings를 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) use ($frontendSettings) { return isset($context['_global']['settings']) && $context['_global']['settings'] === $frontendSettings; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn('
상품
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * render()에서 _global.modules에 모듈 설정이 주입됩니다. */ public function test_render_injects_global_modules_into_context(): void { $request = Request::create('/shop/products'); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $moduleSettings = [ 'sirsoft-ecommerce' => ['basic_info' => ['route_path' => 'shop']], ]; config()->set('g7_settings.modules', $moduleSettings); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['products'], components: [['component' => 'Div', 'props' => ['text' => '상품']]], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['products' => ['data' => []]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품목록')); // context의 _global.modules 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) use ($moduleSettings) { return isset($context['_global']['modules']) && $context['_global']['modules'] === $moduleSettings; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * render()에서 _global.plugins에 플러그인 설정이 주입됩니다. */ public function test_render_injects_global_plugins_into_context(): void { $request = Request::create('/shop/products'); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $pluginSettings = [ 'sirsoft-payment' => ['is_test_mode' => true], ]; $this->pluginSettingsService->shouldReceive('getAllActiveSettings') ->once() ->andReturn($pluginSettings); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['products'], components: [['component' => 'Div', 'props' => ['text' => '상품']]], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['products' => ['data' => []]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품목록')); // context의 _global.plugins 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) use ($pluginSettings) { return isset($context['_global']['plugins']) && $context['_global']['plugins'] === $pluginSettings; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * render()에서 initGlobal 문자열 형식 데이터소스의 결과가 _global에 매핑됩니다. */ public function test_render_applies_init_global_string_mapping(): void { $request = Request::create('/boards/free'); $this->setupRouteResolver('/boards/free', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'boards/list', 'routeParams' => ['slug' => 'free'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['boards'], components: [['component' => 'Div', 'props' => ['text' => '게시판']]], ); // boards 데이터소스에 initGlobal 문자열 형식 설정 $mergedLayout['data_sources'] = [ ['id' => 'boards', 'endpoint' => '/api/boards/menu', 'method' => 'GET', 'initGlobal' => 'boardMenus'], ]; $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); // boards API 응답 $boardsResponse = ['data' => [['id' => 1, 'name' => '자유게시판'], ['id' => 2, 'name' => '공지사항']]]; $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['boards' => $boardsResponse]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '게시판')); // initGlobal 매핑 검증: _global.boardMenus = response.data $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return isset($context['_global']['boardMenus']) && $context['_global']['boardMenus'] === [['id' => 1, 'name' => '자유게시판'], ['id' => 2, 'name' => '공지사항']]; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * render()에서 initGlobal 객체 형식(key + path) 데이터소스의 결과가 _global에 매핑됩니다. */ public function test_render_applies_init_global_object_mapping(): void { $request = Request::create('/shop/products'); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['cart'], components: [['component' => 'Div', 'props' => ['text' => '상품']]], ); // cart 데이터소스에 initGlobal 객체 형식 설정 $mergedLayout['data_sources'] = [ ['id' => 'cart', 'endpoint' => '/api/cart/count', 'method' => 'GET', 'initGlobal' => ['key' => 'cartCount', 'path' => 'count']], ]; $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); // cart API 응답 $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['cart' => ['data' => ['count' => 5, 'items' => []]]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품목록')); // initGlobal 매핑 검증: _global.cartCount = response.data.count $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return isset($context['_global']['cartCount']) && $context['_global']['cartCount'] === 5; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * render()에서 쿼리 파라미터가 DataSourceResolver에 전달됩니다. */ public function test_render_passes_query_params_to_data_source_resolver(): void { $request = Request::create('/shop/products', 'GET', ['page' => '2', 'sort' => 'price_asc']); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['products'], components: [], ); $mergedLayout['data_sources'] = [ ['id' => 'products', 'endpoint' => '/api/products', 'method' => 'GET'], ]; $this->layoutService ->shouldReceive('getLayout') ->once() ->andReturn($mergedLayout); // DataSourceResolver에 쿼리 파라미터가 전달되는지 검증 $this->dataSourceResolver ->shouldReceive('resolve') ->with( Mockery::type('array'), ['products'], [], Mockery::type('string'), Mockery::on(function ($queryParams) { return isset($queryParams['page']) && $queryParams['page'] === '2' && isset($queryParams['sort']) && $queryParams['sort'] === 'price_asc'; }), // 엔드포인트 표현식 평가용 사전 컨텍스트에도 동일한 쿼리가 담긴다 Mockery::on(fn ($preContext) => ($preContext['query']['page'] ?? null) === '2') ) ->once() ->andReturn(['products' => ['data' => []]]); $this->metaResolver ->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품목록')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('상품'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } // ========================================================================= // init_actions → _local 초기화 테스트 // ========================================================================= /** * init_actions의 setState(target: local)이 _local에 반영됩니다. * 쿼리 파라미터 기반 표현식({{query.tab ?? 'info'}})이 올바르게 평가됩니다. */ public function test_render_resolves_init_actions_set_state_to_local(): void { $request = Request::create('/products/123', 'GET', ['tab' => 'reviews']); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [['component' => 'Div', 'props' => ['text' => '상품']]], initActions: [ [ 'handler' => 'loadFromLocalStorage', 'params' => ['key' => 'recentProductIds', 'defaultValue' => []], ], [ 'handler' => 'setState', 'params' => [ 'target' => 'local', 'activeTab' => '{{query.tab ?? \'info\'}}', 'reviewsPage' => 1, 'reviewPhotoOnly' => false, ], ], ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['product' => ['data' => ['name' => '에어맥스']]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '에어맥스')); // ExpressionEvaluator는 실제 인스턴스를 사용하여 {{query.tab ?? 'info'}} 평가 검증 // Mock이므로 evaluate 호출을 캡처 $this->evaluator->shouldReceive('evaluate') ->with("{{query.tab ?? 'info'}}", Mockery::on(function ($context) { return isset($context['query']['tab']) && $context['query']['tab'] === 'reviews'; })) ->andReturn('reviews'); // setSeoOverrides 호출 허용 $this->evaluator->shouldReceive('setSeoOverrides')->byDefault(); // _local.activeTab이 'reviews'로 설정된 context가 htmlMapper.render에 전달되는지 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return isset($context['_local']['activeTab']) && $context['_local']['activeTab'] === 'reviews' && $context['_local']['reviewsPage'] === 1 && $context['_local']['reviewPhotoOnly'] === false; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn('
리뷰 탭
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('리뷰'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * init_actions가 없는 레이아웃에서 _local이 빈 배열로 초기화됩니다. */ public function test_render_returns_empty_local_when_no_init_actions(): void { $request = Request::create('/shop/products'); $this->setupRouteResolver('/shop/products', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['products'], components: [['component' => 'Div', 'props' => []]], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['products' => ['data' => []]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품')); // _local이 빈 배열인지 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return isset($context['_local']) && $context['_local'] === []; }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * init_actions에서 target이 'global'인 setState는 _local에 반영되지 않습니다. */ public function test_render_skips_global_target_set_state_for_local(): void { $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [['component' => 'Div', 'props' => []]], initActions: [ [ 'handler' => 'setState', 'params' => [ 'target' => 'global', 'isNavigatingToCart' => false, ], ], [ 'handler' => 'setState', 'params' => [ 'target' => 'local', 'activeTab' => 'info', ], ], ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['product' => ['data' => ['name' => '에어맥스']]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '에어맥스')); // global setState의 키가 _local에 포함되지 않는지 검증 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return isset($context['_local']['activeTab']) && $context['_local']['activeTab'] === 'info' && ! array_key_exists('isNavigatingToCart', $context['_local']); }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * 레이아웃 최상위 state 블록이 _local 초기값으로 반영됩니다. */ public function test_render_applies_layout_state_block_to_local(): void { $this->runInitialStateTest( layoutArgs: ['state' => ['q' => '', 'view' => 'list']], assertContext: fn ($context) => ($context['_local']['q'] ?? null) === '' && ($context['_local']['view'] ?? null) === 'list', ); } /** * initLocal 블록이 state 블록보다 우선합니다 (state 는 하위 호환 별칭). */ public function test_render_prefers_init_local_over_state_block(): void { $this->runInitialStateTest( layoutArgs: [ 'state' => ['view' => 'list'], 'initLocal' => ['view' => 'grid'], ], assertContext: fn ($context) => ($context['_local']['view'] ?? null) === 'grid', ); } /** * 레이아웃 최상위 initGlobal 블록이 _global 초기값으로 반영됩니다. */ public function test_render_applies_layout_init_global_block(): void { $this->runInitialStateTest( layoutArgs: ['initGlobal' => ['searchActiveTab' => 'all', 'searchPage' => 1]], assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'all' && ($context['_global']['searchPage'] ?? null) === 1, ); } /** * init_actions의 setState(target: global)이 _global에 반영됩니다. */ public function test_render_applies_init_actions_global_set_state_to_global(): void { $this->runInitialStateTest( layoutArgs: [ 'initActions' => [ [ 'handler' => 'setState', 'params' => ['target' => 'global', 'searchSortBy' => 'relevance'], ], ], ], assertContext: fn ($context) => ($context['_global']['searchSortBy'] ?? null) === 'relevance', ); } /** * init_actions의 setState(target: global)이 초기 initGlobal 값을 덮어씁니다. */ public function test_render_init_actions_global_overrides_init_global_block(): void { $this->runInitialStateTest( layoutArgs: [ 'initGlobal' => ['searchActiveTab' => 'all'], 'initActions' => [ [ 'handler' => 'setState', 'params' => ['target' => 'global', 'searchActiveTab' => 'products'], ], ], ], assertContext: fn ($context) => ($context['_global']['searchActiveTab'] ?? null) === 'products', ); } /** * target: global 인 setState가 실은 _local 하위 객체는 _local로 병합됩니다. */ public function test_render_global_set_state_nested_local_is_merged_into_local(): void { $this->runInitialStateTest( layoutArgs: [ 'initActions' => [ [ 'handler' => 'setState', 'params' => [ 'target' => 'global', 'searchActiveTab' => 'all', '_local' => ['q' => '검색어'], ], ], ], ], assertContext: fn ($context) => ($context['_local']['q'] ?? null) === '검색어' && ! array_key_exists('_local', $context['_global']), ); } /** * 레이아웃 초기 상태(state/initLocal/initGlobal/init_actions) 검증 공통 헬퍼입니다. * * @param array $layoutArgs buildMergedLayout 에 전달할 명명 인자 * @param \Closure $assertContext htmlMapper.render 에 전달된 컨텍스트 검증 클로저 */ private function runInitialStateTest(array $layoutArgs, \Closure $assertContext): void { $request = Request::create('/search'); $this->setupRouteResolver('/search', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'search/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(...array_merge([ 'seoEnabled' => true, 'components' => [['component' => 'Div', 'props' => []]], ], $layoutArgs)); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '검색')); $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on($assertContext), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->assertNotNull($this->renderer->render($request)); } /** * init_actions에서 setState가 아닌 핸들러(loadFromLocalStorage, closeModal)는 무시됩니다. */ public function test_render_ignores_non_set_state_handlers_in_init_actions(): void { $request = Request::create('/products/123'); $this->setupRouteResolver('/products/123', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '123'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [['component' => 'Div', 'props' => []]], initActions: [ [ 'handler' => 'loadFromLocalStorage', 'params' => ['key' => 'recentProductIds', 'defaultValue' => []], ], [ 'handler' => 'closeModal', ], [ 'handler' => 'setState', 'params' => [ 'target' => 'local', 'reviewsPage' => 1, ], ], ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve') ->once() ->andReturn(['product' => ['data' => []]]); $this->metaResolver->shouldReceive('resolve') ->once() ->andReturn($this->buildMetaResult(title: '상품')); // loadFromLocalStorage, closeModal의 키가 _local에 없고, setState만 반영 $this->htmlMapper->shouldReceive('render') ->with(Mockery::type('array'), Mockery::on(function ($context) { return $context['_local'] === ['reviewsPage' => 1] && ! array_key_exists('key', $context['_local']); }), Mockery::type(ExpressionEvaluator::class)) ->once() ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } // ========================================================================= // SEO 렌더러 훅 시스템 테스트 (core.seo.filter_context / filter_meta / filter_view_data) // ========================================================================= /** * filter_context 훅: 리스너 미등록 시 기존 동작이 불변합니다. */ public function test_filter_context_hook_no_listener_preserves_behavior(): void { HookManager::clearFilter('core.seo.filter_context'); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->once() ->andReturn(['product' => ['data' => ['name' => '테스트상품']]]); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult(title: '테스트상품')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn('테스트상품'); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * filter_context 훅: 리스너 등록 시 context가 변형되어 후속 단계에 반영됩니다. */ public function test_filter_context_hook_modifies_context_for_meta_resolution(): void { HookManager::clearFilter('core.seo.filter_context'); // 리뷰 플러그인이 reviews_aggregate를 context에 주입하는 시나리오 HookManager::addFilter('core.seo.filter_context', function (array $context, array $hookMeta) { $context['reviews_aggregate'] = ['average' => 4.5, 'count' => 128]; return $context; }); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], components: [], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->once() ->andReturn(['product' => ['data' => ['name' => '에어맥스']]]); // metaResolver.resolve에 전달되는 context에 reviews_aggregate가 포함되어야 함 $this->metaResolver->shouldReceive('resolve') ->once() ->with( Mockery::type('array'), Mockery::on(function ($context) { return isset($context['reviews_aggregate']) && $context['reviews_aggregate']['average'] === 4.5 && $context['reviews_aggregate']['count'] === 128; }), 'sirsoft-ecommerce', null, Mockery::type('array') ) ->andReturn($this->buildMetaResult(title: '에어맥스')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); HookManager::clearFilter('core.seo.filter_context'); } /** * filter_meta 훅: 리스너 미등록 시 기존 동작이 불변합니다. */ public function test_filter_meta_hook_no_listener_preserves_behavior(): void { HookManager::clearFilter('core.seo.filter_meta'); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once() ->andReturn($this->buildMetaResult(title: '원본 타이틀')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return $data['title'] === '원본 타이틀'; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * filter_meta 훅: 리스너 등록 시 meta 값이 변형되어 View에 반영됩니다. */ public function test_filter_meta_hook_modifies_meta_for_view(): void { HookManager::clearFilter('core.seo.filter_meta'); // SEO 플러그인이 title suffix를 변경하는 시나리오 HookManager::addFilter('core.seo.filter_meta', function (array $meta, array $hookMeta) { $meta['titleSuffix'] = ' | A/B Test Variant'; $meta['description'] = '수정된 설명'; return $meta; }); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once() ->andReturn($this->buildMetaResult(title: '상품명', description: '원본 설명')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return $data['title'] === '상품명' && $data['titleSuffix'] === ' | A/B Test Variant' && $data['description'] === '수정된 설명'; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); HookManager::clearFilter('core.seo.filter_meta'); } /** * filter_view_data 훅: 리스너 미등록 시 기본 extraHeadTags/extraBodyEnd가 빈 문자열입니다. */ public function test_filter_view_data_hook_no_listener_has_empty_extension_slots(): void { HookManager::clearFilter('core.seo.filter_view_data'); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once() ->andReturn($this->buildMetaResult(title: '테스트')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) { return $data['extraHeadTags'] === '' && $data['extraBodyEnd'] === ''; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); } /** * filter_view_data 훅: 리스너 등록 시 extraHeadTags/extraBodyEnd에 값이 주입됩니다. */ public function test_filter_view_data_hook_injects_extra_tags(): void { HookManager::clearFilter('core.seo.filter_view_data'); // Analytics 플러그인이 추적 스크립트를 주입하는 시나리오 $trackingScript = ''; $bodyWidget = '
'; HookManager::addFilter('core.seo.filter_view_data', function (array $viewData, array $hookMeta) use ($trackingScript, $bodyWidget) { $viewData['extraHeadTags'] .= $trackingScript; $viewData['extraBodyEnd'] .= $bodyWidget; return $viewData; }); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once() ->andReturn($this->buildMetaResult(title: '테스트')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(function ($data) use ($trackingScript, $bodyWidget) { return $data['extraHeadTags'] === $trackingScript && $data['extraBodyEnd'] === $bodyWidget; })) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); HookManager::clearFilter('core.seo.filter_view_data'); } /** * filter_context 훅: hookMeta에 layoutName, moduleIdentifier 등 메타 정보가 전달됩니다. */ public function test_filter_context_hook_receives_correct_meta(): void { HookManager::clearFilter('core.seo.filter_context'); $receivedMeta = null; HookManager::addFilter('core.seo.filter_context', function (array $context, array $hookMeta) use (&$receivedMeta) { $receivedMeta = $hookMeta; return $context; }); $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'pluginIdentifier' => 'sirsoft-reviews', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout(seoEnabled: true, components: []); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once() ->andReturn($this->buildMetaResult(title: '테스트')); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($receivedMeta); $this->assertEquals('shop/show', $receivedMeta['layoutName']); $this->assertEquals('sirsoft-ecommerce', $receivedMeta['moduleIdentifier']); $this->assertEquals('sirsoft-reviews', $receivedMeta['pluginIdentifier']); $this->assertEquals(['id' => '1'], $receivedMeta['routeParams']); $this->assertArrayHasKey('locale', $receivedMeta); HookManager::clearFilter('core.seo.filter_context'); } // ========================================================================= // computed 속성 해석 테스트 // ========================================================================= /** * computed 문자열 표현식이 evaluateRaw를 통해 평가되고 * _computed/$computed로 context에 전달됩니다. */ public function test_computed_string_expression_evaluates_and_passes_to_context(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], computed: [ 'totalPrice' => '{{product.data.price * 2}}', 'label' => 'static text', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); // evaluateRaw는 {{expr}} 표현식에 대해 호출됨 $this->evaluator->shouldReceive('evaluateRaw') ->with('{{product.data.price * 2}}', Mockery::type('array')) ->once() ->andReturn(258000); // htmlMapper->render에 전달되는 context에서 _computed 확인 $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn('
'); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($capturedContext); $this->assertArrayHasKey('_computed', $capturedContext); $this->assertArrayHasKey('$computed', $capturedContext); $this->assertEquals(258000, $capturedContext['_computed']['totalPrice']); $this->assertEquals('static text', $capturedContext['_computed']['label']); // $computed는 _computed의 별칭 $this->assertEquals($capturedContext['_computed'], $capturedContext['$computed']); } /** * computed $switch 형식이 올바르게 해석됩니다. */ public function test_computed_switch_resolves_matching_case(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], computed: [ 'badgeClass' => [ '$switch' => '{{product.data.status}}', '$cases' => [ 'active' => 'bg-green-100 text-green-800', 'sold_out' => 'bg-red-100 text-red-800', ], '$default' => 'bg-gray-100 text-gray-600', ], ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); // $switch 표현식 평가 → 'active' 반환 $this->evaluator->shouldReceive('evaluate') ->with('{{product.data.status}}', Mockery::type('array')) ->once() ->andReturn('active'); $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertEquals('bg-green-100 text-green-800', $capturedContext['_computed']['badgeClass']); } /** * computed $switch에서 매칭 케이스가 없으면 $default를 반환합니다. */ public function test_computed_switch_falls_back_to_default(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], computed: [ 'badgeClass' => [ '$switch' => '{{product.data.status}}', '$cases' => [ 'active' => 'bg-green-100', ], '$default' => 'bg-gray-100', ], ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); // $switch 표현식이 'unknown_status'를 반환 → $cases에 없음 $this->evaluator->shouldReceive('evaluate') ->with('{{product.data.status}}', Mockery::type('array')) ->once() ->andReturn('unknown_status'); $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertEquals('bg-gray-100', $capturedContext['_computed']['badgeClass']); } /** * computed가 비어있으면 _computed가 context에 추가되지 않습니다. */ public function test_empty_computed_does_not_add_to_context(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); // computed 없는 레이아웃 $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertArrayNotHasKey('_computed', $capturedContext); $this->assertArrayNotHasKey('$computed', $capturedContext); } /** * computed에서 후속 항목이 이전 computed 결과를 참조할 수 있습니다. * (순차 평가: totalPrice → formattedTotal) */ public function test_computed_sequential_reference(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], computed: [ 'totalPrice' => '{{product.data.price}}', 'formattedTotal' => '{{_computed.totalPrice.toLocaleString()}}', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); // 첫 번째 computed 평가 — context에 아직 _computed 없음 $this->evaluator->shouldReceive('evaluateRaw') ->with('{{product.data.price}}', Mockery::on(function ($ctx) { return ! isset($ctx['_computed']['totalPrice']); })) ->once() ->andReturn(129000); // 두 번째 computed 평가 — context._computed.totalPrice가 존재해야 함 $this->evaluator->shouldReceive('evaluateRaw') ->with('{{_computed.totalPrice.toLocaleString()}}', Mockery::on(function ($ctx) { return isset($ctx['_computed']['totalPrice']) && $ctx['_computed']['totalPrice'] === 129000; })) ->once() ->andReturn('129,000'); $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertEquals(129000, $capturedContext['_computed']['totalPrice']); $this->assertEquals('129,000', $capturedContext['_computed']['formattedTotal']); } /** * computed 표현식 평가 실패 시 null이 설정되고 렌더링이 계속됩니다. */ public function test_computed_evaluation_failure_sets_null(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, components: [['component' => 'Div']], computed: [ 'broken' => '{{invalid.reduce((a, b) => a + b)}}', 'valid' => 'still works', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $this->dataSourceResolver->shouldReceive('resolve')->never(); $this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->buildMetaResult()); // 첫 번째 computed는 예외 발생 $this->evaluator->shouldReceive('evaluateRaw') ->with('{{invalid.reduce((a, b) => a + b)}}', Mockery::type('array')) ->once() ->andThrow(new \RuntimeException('Evaluation failed')); $capturedContext = null; $this->htmlMapper->shouldReceive('render') ->once() ->withArgs(function ($components, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->once()->andReturn(''); View::shouldReceive('make')->with('seo', Mockery::type('array'))->once()->andReturn($viewMock); $this->renderer->render($request); // 실패한 computed는 null, 성공한 computed는 정상 $this->assertNull($capturedContext['_computed']['broken']); $this->assertEquals('still works', $capturedContext['_computed']['valid']); } // ========================================================================= // _seo context 주입 테스트 (extensions 기반) // ========================================================================= /** * 상품 상세 페이지에서 extensions + seoVariables()로 _seo.product context가 주입됩니다. */ public function test_seo_context_injected_for_product_page(): void { $request = Request::create('/shop/products/1'); $this->setupRouteResolver('/shop/products/1', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], pageType: 'product', extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']], vars: [ 'product_name' => '{{product.data.name}}', 'product_description' => '{{product.data.description}}', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); // 모듈 인스턴스 mock $moduleMock = Mockery::mock(AbstractModule::class)->makePartial(); $moduleMock->shouldReceive('seoVariables')->andReturn([ '_common' => [ 'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'], ], 'product' => [ 'product_name' => ['source' => 'data', 'required' => true], 'product_description' => ['source' => 'data'], ], ]); $this->moduleManager->shouldReceive('getModule') ->with('sirsoft-ecommerce') ->andReturn($moduleMock); // 설정값 mock config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', '{commerce_name} - {product_name}'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_description', '{product_description}'); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([ 'product' => ['data' => ['name' => '에어맥스', 'description' => '나이키 에어맥스 상품']], ]); // vars 해석 시 evaluator 호출 $this->evaluator->shouldReceive('evaluate') ->with('{{product.data.name}}', Mockery::any()) ->andReturn('에어맥스'); $this->evaluator->shouldReceive('evaluate') ->with('{{product.data.description}}', Mockery::any()) ->andReturn('나이키 에어맥스 상품'); // _seo context 캡처 $capturedContext = null; $this->metaResolver->shouldReceive('resolve') ->once() ->withArgs(function ($seoConfig, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 에어맥스')); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($capturedContext); $this->assertArrayHasKey('_seo', $capturedContext); $this->assertArrayHasKey('product', $capturedContext['_seo']); $this->assertEquals('테스트쇼핑몰 - 에어맥스', $capturedContext['_seo']['product']['title']); $this->assertEquals('나이키 에어맥스 상품', $capturedContext['_seo']['product']['description']); } /** * 카테고리 목록 페이지에서 _seo.category context가 주입됩니다. */ public function test_seo_context_injected_for_category_page(): void { $request = Request::create('/shop/category/shoes'); $this->setupRouteResolver('/shop/category/shoes', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/category', 'routeParams' => ['slug' => 'shoes'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['category'], pageType: 'category', extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']], vars: [ 'category_name' => '{{category.data.name}}', 'category_description' => '{{category.data.description}}', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $moduleMock = Mockery::mock(AbstractModule::class)->makePartial(); $moduleMock->shouldReceive('seoVariables')->andReturn([ '_common' => [ 'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'], ], 'category' => [ 'category_name' => ['source' => 'data', 'required' => true], 'category_description' => ['source' => 'data'], ], ]); $this->moduleManager->shouldReceive('getModule') ->with('sirsoft-ecommerce') ->andReturn($moduleMock); config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_title', '{commerce_name} - {category_name}'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_category_description', '{category_description}'); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([ 'category' => ['data' => ['name' => '신발', 'description' => '신발 카테고리']], ]); $this->evaluator->shouldReceive('evaluate') ->with('{{category.data.name}}', Mockery::any()) ->andReturn('신발'); $this->evaluator->shouldReceive('evaluate') ->with('{{category.data.description}}', Mockery::any()) ->andReturn('신발 카테고리'); $capturedContext = null; $this->metaResolver->shouldReceive('resolve') ->once() ->withArgs(function ($seoConfig, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 신발')); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($capturedContext); $this->assertEquals('테스트쇼핑몰 - 신발', $capturedContext['_seo']['category']['title']); $this->assertEquals('신발 카테고리', $capturedContext['_seo']['category']['description']); } /** * 검색결과 페이지에서 _seo.search context가 주입됩니다 (query source 변수 포함). */ public function test_seo_context_injected_for_search_page(): void { $request = Request::create('/search', 'GET', ['q' => '운동화']); // request() 헬퍼가 이 request를 반환하도록 바인딩 app()->instance('request', $request); $this->setupRouteResolver('/search', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'search/index', 'routeParams' => [], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: [], pageType: 'search', extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $moduleMock = Mockery::mock(AbstractModule::class)->makePartial(); $moduleMock->shouldReceive('seoVariables')->andReturn([ '_common' => [ 'commerce_name' => ['source' => 'setting', 'key' => 'basic_info.shop_name'], ], 'search' => [ 'keyword_name' => ['source' => 'query', 'key' => 'q'], ], ]); $this->moduleManager->shouldReceive('getModule') ->with('sirsoft-ecommerce') ->andReturn($moduleMock); config()->set('g7_settings.modules.sirsoft-ecommerce.basic_info.shop_name', '테스트쇼핑몰'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_search_title', '{commerce_name} - {keyword_name}'); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_search_description', ''); $this->dataSourceResolver->shouldReceive('resolve')->never(); $capturedContext = null; $this->metaResolver->shouldReceive('resolve') ->once() ->withArgs(function ($seoConfig, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn($this->buildMetaResult(title: '테스트쇼핑몰 - 운동화')); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($capturedContext); $this->assertEquals('테스트쇼핑몰 - 운동화', $capturedContext['_seo']['search']['title']); } /** * 설정 템플릿 값이 null인 경우에도 TypeError 없이 정상 동작합니다. */ public function test_seo_context_handles_null_settings_template(): void { $request = Request::create('/shop/products/1'); $this->setupRouteResolver('/shop/products/1', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], pageType: 'product', extensions: [['type' => 'module', 'id' => 'sirsoft-ecommerce']], vars: [ 'product_name' => '{{product.data.name}}', ], ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); $moduleMock = Mockery::mock(AbstractModule::class)->makePartial(); $moduleMock->shouldReceive('seoVariables')->andReturn([ 'product' => [ 'product_name' => ['source' => 'data', 'required' => true], ], ]); $this->moduleManager->shouldReceive('getModule') ->with('sirsoft-ecommerce') ->andReturn($moduleMock); // 설정값을 null로 설정 (미구성 상태 재현) config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_title', null); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.meta_product_description', null); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([ 'product' => ['data' => ['name' => '에어맥스']], ]); $this->evaluator->shouldReceive('evaluate') ->with('{{product.data.name}}', Mockery::any()) ->andReturn('에어맥스'); $capturedContext = null; $this->metaResolver->shouldReceive('resolve') ->once() ->withArgs(function ($seoConfig, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn($this->buildMetaResult(title: '에어맥스')); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); // TypeError 없이 정상 실행되어야 함 $result = $this->renderer->render($request); $this->assertNotNull($result); // null 템플릿 → _seo context에 주입되지 않음 (빈 문자열이므로) $this->assertNotNull($capturedContext); $this->assertArrayNotHasKey('_seo', $capturedContext); } /** * extensions 미선언 시 _seo context가 주입되지 않습니다 (하위호환). */ public function test_seo_context_not_injected_without_extensions(): void { $request = Request::create('/products/1'); $this->setupRouteResolver('/products/1', [ 'templateIdentifier' => 'sirsoft-basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '1'], 'moduleIdentifier' => 'sirsoft-ecommerce', 'routeMeta' => [], ]); $mergedLayout = $this->buildMergedLayout( seoEnabled: true, seoDataSources: ['product'], pageType: 'product', toggleSetting: '$module_settings:seo.seo_product_detail', ); $this->layoutService->shouldReceive('getLayout')->once()->andReturn($mergedLayout); config()->set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', true); $this->dataSourceResolver->shouldReceive('resolve')->once()->andReturn([ 'product' => ['data' => ['name' => '에어맥스']], ]); $capturedContext = null; $this->metaResolver->shouldReceive('resolve') ->once() ->withArgs(function ($seoConfig, $context) use (&$capturedContext) { $capturedContext = $context; return true; }) ->andReturn($this->buildMetaResult(title: '에어맥스')); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); $this->renderer->render($request); $this->assertNotNull($capturedContext); $this->assertArrayNotHasKey('_seo', $capturedContext); } /** * 회귀: filter_og_data 청취자가 og 배열을 수정하면 SeoRenderer 가 * renderOgHtml 을 다시 호출하여 최종 ogTags 에 반영됨. * * 검증 포인트: hook 적용 → 변경 감지 → 재렌더 호출 흐름. * (실제 HTML 출력은 SeoMetaResolverTest 가 단위 레벨로 커버) */ public function test_filter_og_data_hook_triggers_rerender_via_metaresolver(): void { $request = Request::create('/products/99'); $this->setupRouteResolver('/products/99', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'shop/show', 'routeParams' => ['id' => '99'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $this->layoutService->shouldReceive('getLayout')->once()->andReturn( $this->buildMergedLayout(seoEnabled: true, pageType: 'product') ); $this->dataSourceResolver->shouldReceive('resolve')->andReturn([]); // resolve() 가 og 배열 포함 결과 반환 $initialOg = [ 'type' => 'product', 'title' => 'p', 'image' => 'https://e.co/p.jpg', 'image_width' => 800, 'site_name' => 'TestShop', ]; $this->metaResolver->shouldReceive('resolve')->once()->andReturn( $this->buildMetaResult( title: 'p', ogTags: '', og: $initialOg, ) ); // 청취자: image_width 1200 으로 변경 HookManager::addFilter('core.seo.filter_og_data', function (array $og) { $og['image_width'] = 1200; return $og; }); // hook 변경 후 SeoRenderer 가 renderOgHtml 을 다시 호출해야 함 $this->metaResolver->shouldReceive('renderOgHtml') ->once() ->with(Mockery::on(fn ($og) => ($og['image_width'] ?? null) === 1200)) ->andReturn(''); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make') ->with('seo', Mockery::on(fn ($d) => str_contains($d['ogTags'], ''))) ->once() ->andReturn($viewMock); $result = $this->renderer->render($request); $this->assertNotNull($result); // resetAll() 은 부팅 시 등록된 코어/확장 리스너까지 통째로 지운다 — HookManager 가 // static 이라 같은 프로세스의 이후 테스트가 그 영향을 받는다. 이 테스트가 등록한 // 필터만 해제한다 (파일 내 다른 hook 테스트와 동일 패턴). HookManager::clearFilter('core.seo.filter_og_data'); } /** * 회귀: filter_structured_data 청취자가 JSON-LD 배열을 수정하면 재렌더. */ public function test_filter_structured_data_hook_triggers_rerender_via_metaresolver(): void { $request = Request::create('/posts/42'); $this->setupRouteResolver('/posts/42', [ 'templateIdentifier' => 'sirsoft-user_basic', 'layoutName' => 'board/show', 'routeParams' => ['id' => '42'], 'moduleIdentifier' => null, 'routeMeta' => [], ]); $this->layoutService->shouldReceive('getLayout')->once()->andReturn( $this->buildMergedLayout(seoEnabled: true, pageType: 'post') ); $this->dataSourceResolver->shouldReceive('resolve')->andReturn([]); $initialSchema = ['@type' => 'Article', 'headline' => 'h']; $this->metaResolver->shouldReceive('resolve')->once()->andReturn( $this->buildMetaResult( title: 'h', jsonLd: '{"@type":"Article"}', structuredData: $initialSchema, ) ); // 청취자: review 배열 주입 HookManager::addFilter('core.seo.filter_structured_data', function (?array $sd) { if ($sd === null) { return $sd; } $sd['review'] = [['@type' => 'Review', 'reviewBody' => 'Great!']]; return $sd; }); $this->metaResolver->shouldReceive('renderStructuredJson') ->once() ->with(Mockery::on(fn ($sd) => isset($sd['review']) && count($sd['review']) === 1)) ->andReturn('{"@type":"Article","review":[...]}'); $this->htmlMapper->shouldReceive('render')->andReturn(''); $viewMock = Mockery::mock(\Illuminate\View\View::class); $viewMock->shouldReceive('render')->andReturn(''); View::shouldReceive('make')->once()->andReturn($viewMock); $this->renderer->render($request); // resetAll() 대신 이 테스트가 등록한 필터만 해제 (위 og_data 테스트와 동일 사유) HookManager::clearFilter('core.seo.filter_structured_data'); } }