). * 3. SeoMiddleware 가 ?locale=ja 를 유효 로케일로 받아들이고 fallback 하지 않는다. * 4. SeoMiddleware 가 ?locale=ja 캐시를 ko/en 캐시와 격리한다. * * 이 테스트는 ja 가 supported_locales 에 추가되기만 하면 — 실제 ja LanguagePack DB row * 유무와 무관하게 — 다운스트림 SEO 계층이 ja 를 인식해야 한다는 계약을 굳힌다. */ class JaLocaleSeoIntegrationTest extends TestCase { use RefreshDatabase; protected function setUp(): void { parent::setUp(); // ja 가 supported_locales 에 포함된 상태를 직접 주입 (LanguagePackServiceProvider // 의 refreshSupportedLocales() 결과와 동등). bot 감지 + 캐시 격리 검증을 위해 // 봇 UA 가 사용 가능한 상태로 둔다. Config::set('app.locale', 'ko'); Config::set('app.supported_locales', ['ko', 'en', 'ja']); Config::set('app.locale_names', [ 'ko' => '한국어', 'en' => 'English', 'ja' => '日本語', ]); Config::set('app.translatable_locales', ['ko', 'en', 'ja']); // hreflang alternate 방출을 기본값(true)으로 고정한다. fromConfig() 가 읽는 // g7_core_settings('seo.sitemap_hreflang_enabled') 는 부팅 시 영속 설정에서 적재되어 // settings 디스크 fake 의 영향을 받지 않으므로, 다국어 hreflang 검증이 실행 머신의 // 설정 상태에 좌우되지 않도록 명시 고정한다. Config::set('g7_settings.core.seo.sitemap_hreflang_enabled', true); } public function test_sitemap_xml_includes_ja_locale_urls(): void { /** @var SitemapGenerator $generator */ $generator = app(SitemapGenerator::class); $contributor = Mockery::mock(SitemapContributorInterface::class); $contributor->shouldReceive('getIdentifier')->andReturn('ja-test'); $contributor->shouldReceive('getUrls')->andReturn([ ['url' => '/about', 'changefreq' => 'monthly'], ]); $generator->registerContributor($contributor); $xml = $generator->generate(); $base = url('/about'); // 다국어 모드 → xhtml 네임스페이스 활성화 $this->assertStringContainsString( 'xmlns:xhtml="http://www.w3.org/1999/xhtml"', $xml, 'supported_locales 가 1개 초과면 xhtml 네임스페이스가 sitemap 에 포함되어야 함' ); // ja 로케일 항목 — ko 가 default 이므로 ja 는 ?locale=ja 형태 $this->assertStringContainsString( ''.$base.'?locale=ja', $xml, 'ja 가 supported_locales 에 있으면 sitemap 에 ja 로케일 이 생성되어야 함' ); // ja hreflang alternate (다른 로케일들의 안에 ja 가 alternate 로 노출) $this->assertStringContainsString( '', $xml, 'ja hreflang alternate 가 모든 항목에 노출되어야 함' ); // 태그가 3개 이상 (ko/en/ja) $this->assertGreaterThanOrEqual( 3, substr_count($xml, ''), 'ko/en/ja 3개 로케일 항목이 모두 생성되어야 함' ); } public function test_sitemap_xml_default_locale_url_has_no_query_string(): void { /** @var SitemapGenerator $generator */ $generator = app(SitemapGenerator::class); $contributor = Mockery::mock(SitemapContributorInterface::class); $contributor->shouldReceive('getIdentifier')->andReturn('default-loc-test'); $contributor->shouldReceive('getUrls')->andReturn([ ['url' => '/contact'], ]); $generator->registerContributor($contributor); $xml = $generator->generate(); // 기본 ko 로케일은 ?locale=ko 가 아니라 clean URL $this->assertStringContainsString( ''.url('/contact').'', $xml, '기본 로케일(ko) URL 은 ?locale 파라미터 없이 clean URL 이어야 함' ); $this->assertStringNotContainsString( '?locale=ko', $xml, '기본 로케일 URL 에는 ?locale=ko 가 추가되지 않아야 함 (회피 — clean URL 정책)' ); } public function test_seo_middleware_accepts_ja_locale_query(): void { // 봇 요청으로 SEO 렌더링 트리거 Cache::flush(); $response = $this->get('/?locale=ja', [ 'User-Agent' => 'Googlebot/2.1 (+http://www.google.com/bot.html)', ]); // 200 응답이거나 (렌더링 성공) — 401/403/404 가 아니어야 함 (ja 가 거부되지 않았음) $this->assertContains( $response->status(), [200, 301, 302], 'ja 가 supported_locales 에 있으면 SeoMiddleware 가 거부하지 않아야 함 (현재 status='.$response->status().')' ); } public function test_unsupported_locale_still_falls_back_when_ja_active(): void { // 등록되지 않은 'fr' 은 여전히 거부되고 default 로 fallback Cache::flush(); $response = $this->get('/?locale=fr', [ 'User-Agent' => 'Googlebot/2.1 (+http://www.google.com/bot.html)', ]); $this->assertContains( $response->status(), [200, 301, 302], '미지원 locale(fr) 도 fallback 으로 정상 응답해야 함' ); } public function test_seo_cache_isolated_for_ja_locale(): void { Cache::flush(); // ja 로케일과 ko 로케일이 같은 URL 에서 별도 캐시 키를 사용하는지 검증 $cacheManager = app(\App\Seo\SeoCacheManager::class); $reflection = new \ReflectionClass($cacheManager); if (! $reflection->hasMethod('buildKey')) { $this->markTestSkipped('SeoCacheManager::buildKey not accessible'); } $build = $reflection->getMethod('buildKey'); $build->setAccessible(true); $url = 'https://example.test/products'; $keyKo = $build->invoke($cacheManager, $url, 'ko'); $keyEn = $build->invoke($cacheManager, $url, 'en'); $keyJa = $build->invoke($cacheManager, $url, 'ja'); $this->assertNotSame($keyKo, $keyJa, 'ja 캐시 키는 ko 와 격리되어야 함'); $this->assertNotSame($keyEn, $keyJa, 'ja 캐시 키는 en 과 격리되어야 함'); $this->assertNotSame($keyKo, $keyEn, 'ko/en 캐시 키도 격리되어 있어야 함 (회귀 가드)'); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } }