routeResolver = Mockery::mock(TemplateRouteResolver::class);
$this->layoutService = Mockery::mock(LayoutService::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);
$settingsService = Mockery::mock(SettingsService::class);
$settingsService->shouldReceive('getFrontendSettings')->andReturn([])->byDefault();
$pluginSettingsService = Mockery::mock(PluginSettingsService::class);
$pluginSettingsService->shouldReceive('getAllActiveSettings')->andReturn([])->byDefault();
$templateService = Mockery::mock(TemplateService::class);
$templateService->shouldReceive('getLanguageDataWithModules')
->andReturn(['success' => true, 'data' => [], 'error' => null])->byDefault();
$moduleManager = Mockery::mock(ModuleManagerInterface::class);
$pluginManager = Mockery::mock(PluginManagerInterface::class);
// 공통 파이프라인이 건드리는 의존성 mock
$this->seoConfigMerger->shouldReceive('getMergedConfig')->andReturn([])->byDefault();
$pipeRegistry = Mockery::mock(PipeRegistry::class);
$pipeRegistry->shouldReceive('setLocale')->byDefault();
$this->evaluator->shouldReceive('setTranslations')->byDefault();
$this->evaluator->shouldReceive('getPipeRegistry')->andReturn($pipeRegistry)->byDefault();
$this->evaluator->shouldReceive('setSeoOverrides')->byDefault();
foreach (['setComponentMap', 'setRenderModes', 'setSelfClosing', 'setTextProps', 'setAttrMap', 'setAllowedAttrs', 'setGlobalResolver', 'setSeoVars'] as $m) {
$this->htmlMapper->shouldReceive($m)->byDefault();
}
$this->metaResolver->shouldReceive('resolveLocalizedValue')
->andReturnUsing(fn ($v) => is_string($v) ? $v : (string) ($v ?? ''))->byDefault();
$this->renderer = new SeoRenderer(
$this->routeResolver,
$this->layoutService,
$templateService,
$this->dataSourceResolver,
$this->metaResolver,
$this->htmlMapper,
$this->evaluator,
$this->seoConfigMerger,
$settingsService,
$pluginSettingsService,
$moduleManager,
$pluginManager,
);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
/** 최소 meta 결과 — SeoMetaResolver::resolve 흉내 */
private function metaResult(string $title): array
{
return [
'title' => $title,
'titleSuffix' => '',
'description' => '',
'keywords' => '',
'og' => [],
'twitter' => [],
'structured_data' => null,
'ogTags' => '',
'twitterTags' => '',
'jsonLd' => '',
'googleAnalyticsId' => '',
'googleVerification' => '',
'naverVerification' => '',
];
}
private function enabledLayout(): array
{
return [
'meta' => ['seo' => ['enabled' => true]],
'components' => [['component' => 'Div', 'props' => ['text' => '본문']]],
];
}
/**
* renderFromResolved 는 이미 해석된 컨텍스트로 완성 HTML 을 생성한다(편집기 봇 미리보기 경로).
*/
public function test_render_from_resolved_produces_html_from_seed_context(): void
{
$this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->metaResult('봇 제목'));
$this->htmlMapper->shouldReceive('render')->once()->andReturn('
본문
');
$expected = '봇 제목본문
';
$viewMock = Mockery::mock(\Illuminate\View\View::class);
$viewMock->shouldReceive('render')->once()->andReturn($expected);
View::shouldReceive('make')
->with('seo', Mockery::on(fn ($d) => $d['title'] === '봇 제목' && $d['bodyHtml'] === '본문
'))
->once()
->andReturn($viewMock);
$result = $this->renderer->renderFromResolved(
$this->enabledLayout(),
['id' => '1'],
'/preview/test',
'ko',
'sirsoft-basic',
'preview/test',
null,
null,
['route' => ['path' => '/preview/test'], 'query' => [], '_global' => [], '_local' => []],
null,
);
$this->assertSame($expected, $result);
}
/**
* enabled=false 면 renderFromResolved 도 render() 단계3 게이트와 동일하게 null 을 반환한다.
* (편집기 봇 미리보기가 render() 를 거치지 않고 직접 호출해도 "검색엔진 미노출"과 일치.)
*/
public function test_render_from_resolved_returns_null_when_disabled(): void
{
// resolve/render/View 는 호출되면 안 됨 — enabled 게이트에서 조기 반환.
$this->metaResolver->shouldReceive('resolve')->never();
$this->htmlMapper->shouldReceive('render')->never();
$result = $this->renderer->renderFromResolved(
['meta' => ['seo' => ['enabled' => false]], 'components' => []],
[],
'/preview/test',
'ko',
'sirsoft-basic',
'preview/test',
null,
null,
['route' => [], 'query' => [], '_global' => [], '_local' => []],
null,
);
$this->assertNull($result);
}
/**
* meta.seo 자체가 없으면 null (render() 단계3 `! $seoConfig` 와 동일).
*/
public function test_render_from_resolved_returns_null_when_no_seo_config(): void
{
$this->metaResolver->shouldReceive('resolve')->never();
$result = $this->renderer->renderFromResolved(
['meta' => [], 'components' => []],
[],
'/x',
'ko',
'sirsoft-basic',
'x',
null,
null,
[],
null,
);
$this->assertNull($result);
}
/**
* defaultLocale=null 이면 config('app.locale') 폴백으로 canonical 을 구성한다(render() 가
* seo_default_locale request attribute 에서 추출해 넘기는 값의 폴백).
*/
public function test_render_from_resolved_default_locale_falls_back_to_config(): void
{
config()->set('app.locale', 'ko');
$this->metaResolver->shouldReceive('resolve')->once()->andReturn($this->metaResult('제목'));
$this->htmlMapper->shouldReceive('render')->once()->andReturn('');
// locale=ko == defaultLocale(config ko) → canonical 에 ?locale= 없음.
View::shouldReceive('make')
->with('seo', Mockery::on(fn ($d) => ! str_contains($d['canonicalUrl'], '?locale=')))
->once()
->andReturn(tap(Mockery::mock(\Illuminate\View\View::class), fn ($m) => $m->shouldReceive('render')->andReturn('ok')));
$result = $this->renderer->renderFromResolved(
$this->enabledLayout(),
[],
'/page',
'ko',
'sirsoft-basic',
'page',
null,
null,
['route' => [], 'query' => [], '_global' => [], '_local' => []],
null,
);
$this->assertSame('ok', $result);
}
/**
* render(Request) 는 컨텍스트 해석 후 같은 입력에 대해 renderFromResolved 와 동일한 최종
* HTML 을 만든다 — 추출 위임 동등성(부분 목으로 renderFromResolved 호출을 검증).
*/
public function test_render_delegates_to_render_from_resolved_with_resolved_context(): void
{
$request = Request::create('/products/9');
$request->attributes->set('seo_default_locale', 'ko');
$this->routeResolver->shouldReceive('resolve')->with('/products/9')->once()->andReturn([
'templateIdentifier' => 'sirsoft-basic',
'layoutName' => 'shop/show',
'routeParams' => ['id' => '9'],
'moduleIdentifier' => null,
'pluginIdentifier' => null,
]);
$this->layoutService->shouldReceive('getLayout')->with('sirsoft-basic', 'shop/show')->once()
->andReturn($this->enabledLayout());
// render() 본문이 renderFromResolved 에 위임 → 부분 목으로 인자 검증.
$partial = Mockery::mock(SeoRenderer::class, [
$this->routeResolver, $this->layoutService, Mockery::mock(TemplateService::class),
$this->dataSourceResolver, $this->metaResolver, $this->htmlMapper, $this->evaluator,
$this->seoConfigMerger, Mockery::mock(SettingsService::class), Mockery::mock(PluginSettingsService::class),
Mockery::mock(ModuleManagerInterface::class), Mockery::mock(PluginManagerInterface::class),
])->makePartial();
$partial->shouldReceive('renderFromResolved')
->once()
->withArgs(function ($layout, $routeParams, $url, $locale, $tpl, $layoutName, $mod, $plug, $ctx, $default) {
return $url === '/products/9'
&& $routeParams === ['id' => '9']
&& $tpl === 'sirsoft-basic'
&& $layoutName === 'shop/show'
&& $default === 'ko'
&& ($ctx['route']['path'] ?? null) === '/products/9';
})
->andReturn('');
$result = $partial->render($request);
$this->assertSame('', $result);
}
}