대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 1단계로, 전체 URL을 메모리에 적재하던 구조를 스트리밍 분할 생성으로 교체한다. 사이트맵 스트리밍 코어: - SitemapWriter — 자식 파일 1개 분량만 버퍼링하고 임계 도달 시 flush. StorageInterface가 append를 제공하지 않아(put이 전체 덮어쓰기) 택한 구조이며, 최대 메모리가 "자식 파일 1개 크기"로 유계가 된다. URL 수(50,000)와 바이트(45MB) 두 임계로 분할해 sitemaps.org 프로토콜을 지킨다. - 커밋은 _tmp 전량 기록 → live 재배치 → manifest 마지막 기록 순. manifest 존재가 커밋 완료 신호다. 기존 manifest를 먼저 지우지 않아 스왑 중 봇에게 503이 나가지 않는다. - SitemapFileStore — 읽기측 + 파일 경로 SSoT. SitemapXmlRenderer — escape 단일 출처. 데이터 접근 계층 정리: - contributor 3종이 쿼리를 직접 조립하던 것을 Repository 위임으로 전환. MySQL 버퍼드 쿼리에서 cursor는 결과셋 전체를 적재하므로 lazyById를 쓴다. - SeoCacheStatsService → SeoCacheStatRepository 신설 (집계 4쿼리 → 1쿼리). - audit 룰 service-direct-data-access의 사각 2건 수정 — 확장 디렉토리 정규식이 상대경로를 매칭하지 못해 *Contributor처럼 접미사 없는 클래스가 전부 미검출이었다. SEO 캐시 설정 정합화: - SEO 설정의 캐시 3키가 cache 카테고리 형제 키에 항상 져서 운영자 입력이 조용히 무시되고 있었다(설정을 cache 카테고리로 이관하며 SEO 탭 옛 칸을 미제거). 고급 탭을 기준값으로 두되 SEO 탭에 지정이 있으면 오버라이드하도록 SeoCacheSettings 단일 출처로 정리. "지정 여부"는 null로 판정하므로 기본값을 비웠다. - 기존 설치는 Upgrade_7_1_0이 이행 — 옛 기본값과 같으면 미설정으로 비우고, 다르면 운영자 의도로 보아 보존한다. 코어 버전 bump와 루트 CHANGELOG는 계획서 결정(D20)에 따라 마감 단계에서 일괄 수행한다.
792 lines
30 KiB
PHP
792 lines
30 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Seo\Contracts\SitemapContributorInterface;
|
|
use App\Seo\SitemapGenerator;
|
|
use App\Seo\SitemapWriter;
|
|
use App\Seo\TemplateRouteResolver;
|
|
use App\Services\TemplateService;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* SitemapGenerator 단위 테스트
|
|
*
|
|
* 정적 라우트 수집, 기여자 등록/URL 변환, XML 생성 기능을 테스트합니다.
|
|
*
|
|
* 주의: TemplateManager::getActiveTemplate()은 정적 호출이므로
|
|
* 정적 라우트 테스트는 SitemapGenerator를 부분 목(partial mock)으로 생성하여
|
|
* collectStaticRoutes()의 결과를 제어합니다.
|
|
*/
|
|
class SitemapGeneratorTest extends TestCase
|
|
{
|
|
private SitemapGenerator $generator;
|
|
|
|
private TemplateRouteResolver|Mockery\MockInterface $routeResolver;
|
|
|
|
private TemplateService|Mockery\MockInterface $templateService;
|
|
|
|
/**
|
|
* 테스트 초기화 - SitemapGenerator 인스턴스와 의존성 목(Mock)을 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 단일 언어 테스트의 기본 가정: 이후 다국어 테스트에서 명시적으로 override.
|
|
// 활성 언어팩에 따라 변동되는 환경 설정 의존을 제거합니다.
|
|
Config::set('app.locale', 'ko');
|
|
Config::set('app.supported_locales', ['ko']);
|
|
|
|
$this->routeResolver = Mockery::mock(TemplateRouteResolver::class);
|
|
$this->templateService = Mockery::mock(TemplateService::class);
|
|
|
|
$this->generator = new SitemapGenerator(
|
|
$this->routeResolver,
|
|
$this->templateService,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 테스트 종료 - Mockery 리소스를 정리합니다.
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* collectStaticRoutes() 결과를 제어할 수 있도록 리플렉션을 통해
|
|
* 정적 라우트를 주입하는 헬퍼입니다.
|
|
*
|
|
* @param SitemapGenerator $generator 대상 인스턴스
|
|
* @param array $routes 주입할 정적 라우트 배열 (TemplateService::getRoutesDataWithModules 응답의 routes 형식)
|
|
* @param array|null $activeTemplate 활성 템플릿 배열 (null이면 정적 라우트 수집 스킵)
|
|
*/
|
|
private function mockTemplateServiceForStaticRoutes(array $routes, ?array $activeTemplate = null): void
|
|
{
|
|
// TemplateManager가 이미 로드된 상태이므로 alias mock 불가
|
|
// 대신 TemplateManager::getActiveTemplate 정적 호출을 우회하기 위해
|
|
// 실제 TemplateManager를 사용하되, 반환값이 없어 [] 반환되도록 함
|
|
// 정적 라우트 테스트에서는 별도 접근법 사용
|
|
}
|
|
|
|
// ─── 기여자 등록 ──────────────────────────────────────
|
|
|
|
/**
|
|
* 기여자를 등록하면 getContributors()에 포함되는지 확인합니다.
|
|
*/
|
|
public function test_register_contributor_adds_to_list(): void
|
|
{
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test-module');
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
|
|
$contributors = $this->generator->getContributors();
|
|
|
|
$this->assertCount(1, $contributors);
|
|
$this->assertArrayHasKey('test-module', $contributors);
|
|
$this->assertSame($contributor, $contributors['test-module']);
|
|
}
|
|
|
|
/**
|
|
* 동일 식별자로 기여자를 중복 등록하면 마지막 것만 유지되는지 확인합니다.
|
|
*/
|
|
public function test_register_contributor_overwrites_same_identifier(): void
|
|
{
|
|
$contributor1 = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor1->shouldReceive('getIdentifier')->andReturn('same-id');
|
|
|
|
$contributor2 = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor2->shouldReceive('getIdentifier')->andReturn('same-id');
|
|
|
|
$this->generator->registerContributor($contributor1);
|
|
$this->generator->registerContributor($contributor2);
|
|
|
|
$contributors = $this->generator->getContributors();
|
|
|
|
$this->assertCount(1, $contributors);
|
|
$this->assertSame($contributor2, $contributors['same-id']);
|
|
}
|
|
|
|
// ─── generate() - XML 유효성 ──────────────────────────
|
|
|
|
/**
|
|
* generate()가 기여자 URL을 포함한 유효한 sitemap XML을 반환하는지 확인합니다.
|
|
*/
|
|
public function test_generate_returns_valid_xml(): void
|
|
{
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test-contributor');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/products', 'changefreq' => 'daily', 'priority' => 0.8],
|
|
]);
|
|
|
|
// 정적 라우트 수집 시 TemplateManager 접근으로 발생할 수 있는 로그 허용
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
|
|
$xml = $this->generator->generate();
|
|
|
|
// XML 선언 확인
|
|
$this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $xml);
|
|
|
|
// urlset 태그 확인 (다국어 시 xhtml 네임스페이스 포함 가능)
|
|
$this->assertStringContainsString('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"', $xml);
|
|
$this->assertStringEndsWith('</urlset>', $xml);
|
|
|
|
// URL 항목 확인
|
|
$this->assertStringContainsString('<url>', $xml);
|
|
$this->assertStringContainsString('<loc>', $xml);
|
|
$this->assertStringContainsString('<changefreq>daily</changefreq>', $xml);
|
|
$this->assertStringContainsString('<priority>0.8</priority>', $xml);
|
|
}
|
|
|
|
// ─── generate() - 정적 라우트 수집 ───────────────────────
|
|
|
|
/**
|
|
* generate()가 정적 라우트를 올바르게 수집하는지 확인합니다.
|
|
*
|
|
* TemplateManager 정적 호출을 우회하기 위해 리플렉션으로 collectStaticRoutes를 직접 테스트합니다.
|
|
*/
|
|
public function test_generate_includes_static_routes(): void
|
|
{
|
|
$this->templateService->shouldReceive('getRoutesDataWithModules')
|
|
->with('sirsoft-user_basic')
|
|
->andReturn([
|
|
'success' => true,
|
|
'data' => [
|
|
'routes' => [
|
|
['path' => '/about', 'auth_required' => false, 'guest_only' => false],
|
|
['path' => '/contact', 'auth_required' => false, 'guest_only' => false],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// 리플렉션으로 collectStaticRoutes를 호출하되, TemplateManager 정적 호출을 우회
|
|
$result = $this->invokeCollectStaticRoutes(['identifier' => 'sirsoft-user_basic']);
|
|
|
|
$this->assertCount(2, $result);
|
|
$this->assertStringContainsString('/about', $result[0]['loc']);
|
|
$this->assertStringContainsString('/contact', $result[1]['loc']);
|
|
$this->assertEquals('weekly', $result[0]['changefreq']);
|
|
$this->assertEquals(0.5, $result[0]['priority']);
|
|
}
|
|
|
|
/**
|
|
* auth_required 라우트가 제외되는지 확인합니다.
|
|
*/
|
|
public function test_generate_excludes_auth_required_routes(): void
|
|
{
|
|
$this->templateService->shouldReceive('getRoutesDataWithModules')
|
|
->with('sirsoft-user_basic')
|
|
->andReturn([
|
|
'success' => true,
|
|
'data' => [
|
|
'routes' => [
|
|
['path' => '/public-page', 'auth_required' => false, 'guest_only' => false],
|
|
['path' => '/my-account', 'auth_required' => true, 'guest_only' => false],
|
|
['path' => '/dashboard', 'auth_required' => true, 'guest_only' => false],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$result = $this->invokeCollectStaticRoutes(['identifier' => 'sirsoft-user_basic']);
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertStringContainsString('/public-page', $result[0]['loc']);
|
|
}
|
|
|
|
/**
|
|
* 동적 파라미터(:)가 포함된 라우트가 제외되는지 확인합니다.
|
|
*/
|
|
public function test_generate_excludes_dynamic_routes(): void
|
|
{
|
|
$this->templateService->shouldReceive('getRoutesDataWithModules')
|
|
->with('sirsoft-user_basic')
|
|
->andReturn([
|
|
'success' => true,
|
|
'data' => [
|
|
'routes' => [
|
|
['path' => '/products', 'auth_required' => false, 'guest_only' => false],
|
|
['path' => '/products/:id', 'auth_required' => false, 'guest_only' => false],
|
|
['path' => '/categories/:slug/items', 'auth_required' => false, 'guest_only' => false],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$result = $this->invokeCollectStaticRoutes(['identifier' => 'sirsoft-user_basic']);
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertStringContainsString('/products', $result[0]['loc']);
|
|
// 동적 라우트가 포함되지 않았는지 확인
|
|
foreach ($result as $entry) {
|
|
$this->assertStringNotContainsString(':id', $entry['loc']);
|
|
$this->assertStringNotContainsString(':slug', $entry['loc']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 표현식({{}})이 포함된 라우트가 제외되는지 확인합니다.
|
|
*/
|
|
public function test_generate_excludes_template_expression_routes(): void
|
|
{
|
|
$this->templateService->shouldReceive('getRoutesDataWithModules')
|
|
->with('sirsoft-user_basic')
|
|
->andReturn([
|
|
'success' => true,
|
|
'data' => [
|
|
'routes' => [
|
|
['path' => '/home', 'auth_required' => false, 'guest_only' => false],
|
|
['path' => '/items/{{category}}', 'auth_required' => false, 'guest_only' => false],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$result = $this->invokeCollectStaticRoutes(['identifier' => 'sirsoft-user_basic']);
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertStringContainsString('/home', $result[0]['loc']);
|
|
}
|
|
|
|
// ─── generate() - 기여자 예외 처리 ──────────────────────
|
|
|
|
/**
|
|
* 기여자에서 예외 발생 시 다른 기여자의 URL이 정상 수집되고 로그가 기록되는지 확인합니다.
|
|
*/
|
|
public function test_generate_handles_contributor_exception_gracefully(): void
|
|
{
|
|
// 예외를 던지는 기여자
|
|
$failingContributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$failingContributor->shouldReceive('getIdentifier')->andReturn('failing-module');
|
|
$failingContributor->shouldReceive('getUrls')->andThrow(new \RuntimeException('DB connection failed'));
|
|
|
|
// 정상 기여자
|
|
$workingContributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$workingContributor->shouldReceive('getIdentifier')->andReturn('working-module');
|
|
$workingContributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/working-page', 'changefreq' => 'monthly', 'priority' => 0.6],
|
|
]);
|
|
|
|
// 기여자 실패 로그 검증
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->withArgs(function (string $message, array $context) {
|
|
return str_contains($message, 'Sitemap contributor failed')
|
|
&& $context['contributor'] === 'failing-module'
|
|
&& str_contains($context['error'], 'DB connection failed');
|
|
});
|
|
|
|
// 정적 라우트 수집에서 발생할 수 있는 로그 허용
|
|
Log::shouldReceive('warning')
|
|
->zeroOrMoreTimes()
|
|
->withArgs(function (string $message) {
|
|
return str_contains($message, 'Static route collection failed');
|
|
});
|
|
|
|
$this->generator->registerContributor($failingContributor);
|
|
$this->generator->registerContributor($workingContributor);
|
|
|
|
$xml = $this->generator->generate();
|
|
|
|
// 정상 기여자의 URL은 포함되어야 함
|
|
$this->assertStringContainsString(url('/working-page'), $xml);
|
|
$this->assertStringContainsString('<changefreq>monthly</changefreq>', $xml);
|
|
}
|
|
|
|
// ─── generate() - URL 변환 ──────────────────────────────
|
|
|
|
/**
|
|
* 기여자의 url 키가 절대 경로 loc으로 변환되는지 확인합니다.
|
|
*/
|
|
public function test_generate_converts_contributor_url_to_absolute_loc(): void
|
|
{
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('ecommerce');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/products/shoes', 'lastmod' => '2026-03-01', 'priority' => 0.9],
|
|
['url' => '/products/bags'],
|
|
]);
|
|
|
|
// 정적 라우트 수집 로그 허용
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
|
|
$xml = $this->generator->generate();
|
|
|
|
// 상대 URL이 절대 URL(loc)로 변환되었는지 확인
|
|
$expectedAbsoluteUrl1 = url('/products/shoes');
|
|
$expectedAbsoluteUrl2 = url('/products/bags');
|
|
|
|
$this->assertStringContainsString("<loc>{$expectedAbsoluteUrl1}</loc>", $xml);
|
|
$this->assertStringContainsString("<loc>{$expectedAbsoluteUrl2}</loc>", $xml);
|
|
$this->assertStringContainsString('<lastmod>2026-03-01</lastmod>', $xml);
|
|
$this->assertStringContainsString('<priority>0.9</priority>', $xml);
|
|
|
|
// 원본 상대 URL이 loc 태그에 직접 나타나지 않는지 확인
|
|
$this->assertStringNotContainsString('<loc>/products/shoes</loc>', $xml);
|
|
}
|
|
|
|
// ─── generate() - 빈 결과 ──────────────────────────────
|
|
|
|
/**
|
|
* 기여자와 정적 라우트가 모두 없을 때 빈 urlset XML을 반환하는지 확인합니다.
|
|
*/
|
|
public function test_generate_with_no_contributors_returns_empty_urlset(): void
|
|
{
|
|
// 정적 라우트 수집 로그 허용
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$xml = $this->generator->generate();
|
|
|
|
$this->assertStringStartsWith('<?xml version="1.0" encoding="UTF-8"?>', $xml);
|
|
$this->assertStringContainsString('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"', $xml);
|
|
$this->assertStringEndsWith('</urlset>', $xml);
|
|
|
|
// URL 항목이 없어야 함
|
|
$this->assertStringNotContainsString('<url>', $xml);
|
|
$this->assertStringNotContainsString('<loc>', $xml);
|
|
}
|
|
|
|
// ─── 다국어 sitemap 테스트 ──────────────────────────────
|
|
|
|
/**
|
|
* 다국어 지원 시 xmlns:xhtml 네임스페이스가 포함되는지 확인합니다.
|
|
*/
|
|
public function test_multilingual_xml_includes_xhtml_namespace(): void
|
|
{
|
|
config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]);
|
|
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/products', 'changefreq' => 'daily'],
|
|
]);
|
|
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
$xml = $this->generator->generate();
|
|
|
|
$this->assertStringContainsString('xmlns:xhtml="http://www.w3.org/1999/xhtml"', $xml);
|
|
}
|
|
|
|
/**
|
|
* 다국어 지원 시 각 로케일별 <url> 항목이 생성되는지 확인합니다.
|
|
*/
|
|
public function test_multilingual_xml_generates_url_per_locale(): void
|
|
{
|
|
config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]);
|
|
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/about'],
|
|
]);
|
|
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
$xml = $this->generator->generate();
|
|
|
|
// 기본 로케일 URL (파라미터 없음)
|
|
$baseUrl = url('/about');
|
|
$this->assertStringContainsString('<loc>'.$baseUrl.'</loc>', $xml);
|
|
|
|
// 비기본 로케일 URL (?locale=en)
|
|
$this->assertStringContainsString('<loc>'.$baseUrl.'?locale=en</loc>', $xml);
|
|
|
|
// <url> 태그가 2개 이상 (로케일 수만큼)
|
|
$this->assertGreaterThanOrEqual(2, substr_count($xml, '<url>'));
|
|
}
|
|
|
|
/**
|
|
* 다국어 지원 시 xhtml:link hreflang alternate 태그가 포함되는지 확인합니다.
|
|
*/
|
|
public function test_multilingual_xml_includes_hreflang_alternates(): void
|
|
{
|
|
config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]);
|
|
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/products'],
|
|
]);
|
|
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
$xml = $this->generator->generate();
|
|
|
|
$baseUrl = url('/products');
|
|
|
|
// ko hreflang
|
|
$this->assertStringContainsString(
|
|
'<xhtml:link rel="alternate" hreflang="ko" href="'.$baseUrl.'"/>',
|
|
$xml
|
|
);
|
|
|
|
// en hreflang
|
|
$this->assertStringContainsString(
|
|
'<xhtml:link rel="alternate" hreflang="en" href="'.$baseUrl.'?locale=en"/>',
|
|
$xml
|
|
);
|
|
|
|
// x-default
|
|
$this->assertStringContainsString(
|
|
'<xhtml:link rel="alternate" hreflang="x-default" href="'.$baseUrl.'"/>',
|
|
$xml
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단일 로케일 시 기존 형식(xhtml 네임스페이스 없음)을 유지하는지 확인합니다.
|
|
*/
|
|
public function test_single_locale_uses_standard_xml_format(): void
|
|
{
|
|
config(['app.locale' => 'ko', 'app.supported_locales' => ['ko']]);
|
|
|
|
$contributor = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributor->shouldReceive('getIdentifier')->andReturn('test');
|
|
$contributor->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/products'],
|
|
]);
|
|
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($contributor);
|
|
$xml = $this->generator->generate();
|
|
|
|
// xhtml 네임스페이스 없음
|
|
$this->assertStringNotContainsString('xmlns:xhtml', $xml);
|
|
|
|
// xhtml:link 없음
|
|
$this->assertStringNotContainsString('xhtml:link', $xml);
|
|
|
|
// 기존 형식 유지
|
|
$this->assertStringContainsString('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', $xml);
|
|
$this->assertEquals(1, substr_count($xml, '<url>'));
|
|
}
|
|
|
|
// ─── renderUrlBlock / urlset 헤더·푸터 ──────────────────
|
|
|
|
/**
|
|
* renderUrlBlock()이 단일 언어 <url> 블록을 생성하는지 확인합니다.
|
|
*/
|
|
public function test_render_url_block_single_locale(): void
|
|
{
|
|
$block = $this->generator->renderUrlBlock([
|
|
'loc' => 'https://example.test/a',
|
|
'changefreq' => 'daily',
|
|
'priority' => 0.8,
|
|
]);
|
|
|
|
$this->assertStringContainsString('<loc>https://example.test/a</loc>', $block);
|
|
$this->assertStringContainsString('<changefreq>daily</changefreq>', $block);
|
|
$this->assertStringContainsString('<priority>0.8</priority>', $block);
|
|
$this->assertStringNotContainsString('xhtml:link', $block);
|
|
$this->assertSame(1, substr_count($block, '<url>'));
|
|
}
|
|
|
|
/**
|
|
* renderUrlBlock()이 다국어에서 로케일별 블록과 hreflang을 생성하는지 확인합니다.
|
|
*/
|
|
public function test_render_url_block_multilingual(): void
|
|
{
|
|
config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]);
|
|
|
|
$block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a']);
|
|
|
|
$this->assertSame(2, substr_count($block, '<url>'));
|
|
$this->assertStringContainsString('<loc>https://example.test/a</loc>', $block);
|
|
$this->assertStringContainsString('<loc>https://example.test/a?locale=en</loc>', $block);
|
|
$this->assertStringContainsString('hreflang="x-default"', $block);
|
|
}
|
|
|
|
/**
|
|
* XML 특수문자가 이스케이프되는지 확인합니다.
|
|
*/
|
|
public function test_render_url_block_escapes_xml_special_characters(): void
|
|
{
|
|
$block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a?x=1&y=2']);
|
|
|
|
$this->assertStringContainsString('<loc>https://example.test/a?x=1&y=2</loc>', $block);
|
|
// 이스케이프되지 않은 원본 & 가 남아 있으면 XML 파싱이 깨집니다.
|
|
$this->assertNotFalse(simplexml_load_string('<urlset>'.$block.'</urlset>'));
|
|
}
|
|
|
|
/**
|
|
* urlset 헤더/푸터가 로케일 수에 따라 분기되는지 확인합니다.
|
|
*/
|
|
public function test_urlset_header_switches_on_multilingual(): void
|
|
{
|
|
$this->assertStringNotContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader());
|
|
$this->assertSame('</urlset>', $this->generator->buildUrlsetFooter());
|
|
|
|
config(['app.supported_locales' => ['ko', 'en']]);
|
|
|
|
$this->assertStringContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader());
|
|
}
|
|
|
|
// ─── generateToWriter ────────────────────────────────
|
|
|
|
/**
|
|
* generateToWriter()가 기여자 URL을 writer 에 한 건씩 전달하고
|
|
* 상대 url 을 절대 loc 으로 변환하는지 확인합니다.
|
|
*/
|
|
public function test_generate_to_writer_streams_entries_without_cross_merge(): void
|
|
{
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$contributorA = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributorA->shouldReceive('getIdentifier')->andReturn('mod-a');
|
|
$contributorA->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/a/1'],
|
|
['url' => '/a/2'],
|
|
]);
|
|
|
|
$contributorB = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributorB->shouldReceive('getIdentifier')->andReturn('mod-b');
|
|
$contributorB->shouldReceive('getUrls')->andReturn([
|
|
['url' => '/b/1'],
|
|
]);
|
|
|
|
$this->generator->registerContributor($contributorA);
|
|
$this->generator->registerContributor($contributorB);
|
|
|
|
$writer = new SpySitemapWriter;
|
|
$meta = $this->generator->generateToWriter($writer);
|
|
|
|
$this->assertTrue($writer->opened);
|
|
$this->assertTrue($writer->closed);
|
|
$this->assertTrue($writer->committed);
|
|
$this->assertSame(['committed' => true], $meta);
|
|
|
|
$locs = array_column($writer->entries, 'loc');
|
|
$this->assertContains(url('/a/1'), $locs);
|
|
$this->assertContains(url('/a/2'), $locs);
|
|
$this->assertContains(url('/b/1'), $locs);
|
|
|
|
// 'url' 키는 절대 loc 으로 변환되어 남지 않아야 합니다.
|
|
foreach ($writer->entries as $entry) {
|
|
$this->assertArrayNotHasKey('url', $entry);
|
|
$this->assertArrayHasKey('loc', $entry);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 기여자를 하나씩 소진하여 writer 로 흘려보내는지 확인합니다 (전체 병합 부재 증명).
|
|
*
|
|
* 모든 기여자의 URL 을 배열로 모은 뒤 한 번에 쓰는 구현이라면, 두 번째 기여자의
|
|
* getUrls() 가 호출되는 시점에 writer 는 아직 비어 있다. 이 테스트는 그 구현을 걸러낸다.
|
|
*/
|
|
public function test_generate_to_writer_drains_each_contributor_before_calling_the_next(): void
|
|
{
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$writer = new SpySitemapWriter;
|
|
|
|
$contributorA = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributorA->shouldReceive('getIdentifier')->andReturn('mod-a');
|
|
$contributorA->shouldReceive('getUrls')->andReturn([['url' => '/a/1']]);
|
|
|
|
$locsSeenWhenBWasAsked = null;
|
|
|
|
$contributorB = Mockery::mock(SitemapContributorInterface::class);
|
|
$contributorB->shouldReceive('getIdentifier')->andReturn('mod-b');
|
|
$contributorB->shouldReceive('getUrls')->andReturnUsing(
|
|
function () use ($writer, &$locsSeenWhenBWasAsked): array {
|
|
$locsSeenWhenBWasAsked = array_column($writer->entries, 'loc');
|
|
|
|
return [['url' => '/b/1']];
|
|
}
|
|
);
|
|
|
|
$this->generator->registerContributor($contributorA);
|
|
$this->generator->registerContributor($contributorB);
|
|
$this->generator->generateToWriter($writer);
|
|
|
|
$this->assertNotNull($locsSeenWhenBWasAsked, 'B 기여자가 호출되어야 합니다.');
|
|
$this->assertContains(
|
|
url('/a/1'),
|
|
$locsSeenWhenBWasAsked,
|
|
'B 를 호출하기 전에 A 의 URL 이 이미 writer 로 전달되어야 합니다 (전체 병합 금지).'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 한 기여자가 실패해도 다른 기여자의 URL 이 계속 기록되는지 확인합니다.
|
|
*/
|
|
public function test_generate_to_writer_isolates_contributor_failure(): void
|
|
{
|
|
$failing = Mockery::mock(SitemapContributorInterface::class);
|
|
$failing->shouldReceive('getIdentifier')->andReturn('failing-module');
|
|
$failing->shouldReceive('getUrls')->andThrow(new \RuntimeException('DB connection failed'));
|
|
|
|
$working = Mockery::mock(SitemapContributorInterface::class);
|
|
$working->shouldReceive('getIdentifier')->andReturn('working-module');
|
|
$working->shouldReceive('getUrls')->andReturn([['url' => '/ok']]);
|
|
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->withArgs(fn (string $message, array $context) => str_contains($message, 'Sitemap contributor failed')
|
|
&& $context['contributor'] === 'failing-module');
|
|
Log::shouldReceive('warning')->zeroOrMoreTimes();
|
|
|
|
$this->generator->registerContributor($failing);
|
|
$this->generator->registerContributor($working);
|
|
|
|
$writer = new SpySitemapWriter;
|
|
$this->generator->generateToWriter($writer);
|
|
|
|
$this->assertContains(url('/ok'), array_column($writer->entries, 'loc'));
|
|
}
|
|
|
|
// ─── 헬퍼 메서드 ──────────────────────────────────────
|
|
|
|
/**
|
|
* TemplateManager 정적 호출을 우회하여 collectStaticRoutes를 테스트합니다.
|
|
*
|
|
* 리플렉션으로 private 메서드에 접근하되, TemplateManager::getActiveTemplate() 호출 부분을
|
|
* 우회하기 위해 getRoutesDataWithModules()의 결과만 검증합니다.
|
|
*
|
|
* @param array|null $activeTemplate 활성 템플릿 데이터
|
|
* @return array 수집된 정적 라우트 배열
|
|
*/
|
|
private function invokeCollectStaticRoutes(?array $activeTemplate): array
|
|
{
|
|
// TemplateManager::getActiveTemplate을 우회하기 위해
|
|
// collectStaticRoutes 내부 로직을 시뮬레이션합니다.
|
|
// (private 메서드를 직접 테스트하는 대신, generate()를 통해 간접 테스트하되
|
|
// 정적 라우트 부분만 격리)
|
|
|
|
if (! $activeTemplate) {
|
|
return [];
|
|
}
|
|
|
|
$templateIdentifier = $activeTemplate['identifier'] ?? null;
|
|
if (! $templateIdentifier) {
|
|
return [];
|
|
}
|
|
|
|
$routesResult = $this->templateService->getRoutesDataWithModules($templateIdentifier);
|
|
if (! ($routesResult['success'] ?? false) || empty($routesResult['data']['routes'])) {
|
|
return [];
|
|
}
|
|
|
|
$urls = [];
|
|
foreach ($routesResult['data']['routes'] as $route) {
|
|
if ($route['auth_required'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
if ($route['guest_only'] ?? false) {
|
|
continue;
|
|
}
|
|
|
|
$routePath = $route['path'] ?? '';
|
|
|
|
if (str_contains($routePath, ':')) {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($routePath, '{{')) {
|
|
continue;
|
|
}
|
|
|
|
$routePath = ltrim($routePath, '*/');
|
|
if (! str_starts_with($routePath, '/')) {
|
|
$routePath = '/'.$routePath;
|
|
}
|
|
|
|
$urls[] = [
|
|
'loc' => url($routePath),
|
|
'changefreq' => 'weekly',
|
|
'priority' => 0.5,
|
|
];
|
|
}
|
|
|
|
return $urls;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SitemapWriter 호출을 기록하는 테스트 더블.
|
|
*
|
|
* generateToWriter 가 URL 을 한 건씩 스트리밍하는지(전체 배열 병합 없음)와
|
|
* open/close/commit 생명주기를 검증하기 위해 사용합니다.
|
|
* 스토리지 없이 동작하도록 부모 생성자를 호출하지 않습니다.
|
|
*/
|
|
class SpySitemapWriter extends SitemapWriter
|
|
{
|
|
/**
|
|
* open() 호출 여부
|
|
*/
|
|
public bool $opened = false;
|
|
|
|
/**
|
|
* close() 호출 여부
|
|
*/
|
|
public bool $closed = false;
|
|
|
|
/**
|
|
* commit() 호출 여부
|
|
*/
|
|
public bool $committed = false;
|
|
|
|
/**
|
|
* addUrl() 로 전달된 URL 항목 목록
|
|
*
|
|
* @var array<int, array<string, mixed>>
|
|
*/
|
|
public array $entries = [];
|
|
|
|
/**
|
|
* SpySitemapWriter 생성자 (의존성 없이 생성).
|
|
*/
|
|
public function __construct() {}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function open(): void
|
|
{
|
|
$this->opened = true;
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function addUrl(array $entry): void
|
|
{
|
|
$this->entries[] = $entry;
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function close(): array
|
|
{
|
|
$this->closed = true;
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function commit(): array
|
|
{
|
|
$this->committed = true;
|
|
|
|
return ['committed' => true];
|
|
}
|
|
}
|