대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
385 lines
14 KiB
PHP
385 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Extension\HookManager;
|
|
use App\Extension\Storage\CoreStorageDriver;
|
|
use App\Seo\Contracts\SitemapContributorInterface;
|
|
use App\Seo\SitemapFileStore;
|
|
use App\Seo\SitemapGenerator;
|
|
use App\Seo\SitemapManager;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* SitemapManager::regenerate 단위 테스트
|
|
*
|
|
* regenerate() 는 S1 의 4대 산출물 중 하나이며 S2 의 Controller 가 반환 메타를 소비한다.
|
|
* 기존 테스트(SeoCacheControllerTest / GenerateSitemapJobTest)는 SitemapManager 를 통째로
|
|
* mock 하므로 본문이 한 번도 실행되지 않는다 — 이 테스트가 그 공백을 메운다.
|
|
*
|
|
* 검증 목적:
|
|
* - sitemap_enabled=false 시 disabled 반환 + 훅 미발화
|
|
* - 성공 시 반환 메타 5키 · 캐시(구 XML 키 정리 + 메타 저장) · last_updated_at 기록 · after_regenerate 훅
|
|
* - 실패 시 failed 반환 + after_regenerate_failed 훅 발화(페이로드 포함)
|
|
* - writer 가 설정(urls_per_file / gzip)을 반영
|
|
* - ttl 폴백 사슬
|
|
*/
|
|
class SitemapManagerTest extends TestCase
|
|
{
|
|
/**
|
|
* 이 테스트가 등록·검증하는 sitemap 훅 목록
|
|
*/
|
|
private const SITEMAP_HOOKS = [
|
|
'core.seo.sitemap.before_regenerate',
|
|
'core.seo.sitemap.after_regenerate',
|
|
'core.seo.sitemap.after_regenerate_failed',
|
|
];
|
|
|
|
private CoreStorageDriver $storage;
|
|
|
|
/**
|
|
* 테스트 초기화 - 가짜 디스크와 훅 레지스트리를 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Config::set('app.locale', 'ko');
|
|
Config::set('app.supported_locales', ['ko']);
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', true);
|
|
|
|
Storage::fake('local');
|
|
$this->storage = new CoreStorageDriver('local');
|
|
|
|
$this->clearSitemapHooks();
|
|
}
|
|
|
|
/**
|
|
* 테스트 정리 - 등록한 훅 리스너가 다른 테스트로 누출되지 않도록 제거합니다.
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
$this->clearSitemapHooks();
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* sitemap 훅 리스너를 모두 제거합니다.
|
|
*/
|
|
private function clearSitemapHooks(): void
|
|
{
|
|
foreach (self::SITEMAP_HOOKS as $hook) {
|
|
HookManager::clearAction($hook);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 지정한 URL 을 내보내는 가짜 contributor 를 만듭니다.
|
|
*
|
|
* @param array<int, string> $urls 상대 경로 목록
|
|
* @return SitemapContributorInterface 가짜 기여자
|
|
*/
|
|
private function fakeContributor(array $urls): SitemapContributorInterface
|
|
{
|
|
return new class($urls) implements SitemapContributorInterface
|
|
{
|
|
/**
|
|
* @param array<int, string> $urls 상대 경로 목록
|
|
*/
|
|
public function __construct(private array $urls) {}
|
|
|
|
public function getIdentifier(): string
|
|
{
|
|
return 'fake-contributor';
|
|
}
|
|
|
|
public function getUrls(): array
|
|
{
|
|
return array_map(fn (string $url): array => ['url' => $url], $this->urls);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 테스트용 SitemapManager 를 만듭니다.
|
|
*
|
|
* @param array<int, string> $urls 기여자가 내보낼 URL 목록
|
|
* @param CacheInterface|null $cache 캐시 (미지정 시 기대 없는 spy)
|
|
* @param ConfigRepositoryInterface|null $config 설정 Repository
|
|
* @return SitemapManager manager 인스턴스
|
|
*/
|
|
private function makeManager(
|
|
array $urls = ['/a'],
|
|
?CacheInterface $cache = null,
|
|
?ConfigRepositoryInterface $config = null,
|
|
): SitemapManager {
|
|
// generator 는 라우트 해석기/템플릿 서비스에 의존하므로 컨테이너로 해석합니다.
|
|
$generator = $this->app->make(SitemapGenerator::class);
|
|
$generator->registerContributor($this->fakeContributor($urls));
|
|
|
|
return new SitemapManager(
|
|
$generator,
|
|
$cache ?? Mockery::spy(CacheInterface::class),
|
|
$config ?? Mockery::spy(ConfigRepositoryInterface::class),
|
|
$this->storage,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* regenerate: sitemap 이 비활성화되면 생성하지 않고 disabled 를 반환한다
|
|
*/
|
|
public function test_regenerate_returns_disabled_when_sitemap_is_disabled(): void
|
|
{
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', false);
|
|
|
|
$fired = [];
|
|
HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$fired) {
|
|
$fired[] = 'before';
|
|
});
|
|
|
|
$result = $this->makeManager()->regenerate();
|
|
|
|
$this->assertFalse($result['success']);
|
|
$this->assertSame('disabled', $result['status']);
|
|
$this->assertArrayNotHasKey('data', $result);
|
|
$this->assertSame([], $fired, '비활성화 시 훅이 발화하면 안 됩니다.');
|
|
$this->assertFalse(
|
|
$this->storage->exists(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE),
|
|
'비활성화 시 파일을 커밋하면 안 됩니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 성공 시 status=updated 와 메타 5키를 반환한다
|
|
*/
|
|
public function test_regenerate_returns_updated_with_meta_payload(): void
|
|
{
|
|
$result = $this->makeManager(['/a', '/b'])->regenerate();
|
|
|
|
$this->assertTrue($result['success']);
|
|
$this->assertSame('updated', $result['status']);
|
|
$this->assertSame(
|
|
['last_updated_at', 'size_bytes', 'url_count', 'child_count', 'ttl'],
|
|
array_keys($result['data']),
|
|
'S2 Controller 가 소비하는 메타 키 집합이 고정되어야 합니다.'
|
|
);
|
|
$this->assertSame(1, $result['data']['child_count']);
|
|
$this->assertGreaterThan(0, $result['data']['size_bytes']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 성공 시 sitemap 세트가 디스크에 커밋된다
|
|
*/
|
|
public function test_regenerate_commits_sitemap_set_to_disk(): void
|
|
{
|
|
$this->makeManager(['/a'])->regenerate();
|
|
|
|
$store = new SitemapFileStore($this->storage);
|
|
|
|
$this->assertTrue($store->exists());
|
|
$this->assertNotNull($store->indexResponse());
|
|
$this->assertNotNull($store->childResponse(1));
|
|
}
|
|
|
|
/**
|
|
* regenerate: 구 XML 본문 캐시를 정리하고 메타만 캐시에 저장한다
|
|
*/
|
|
public function test_regenerate_forgets_legacy_xml_cache_and_puts_meta(): void
|
|
{
|
|
Config::set('g7_settings.core.cache.seo_sitemap_ttl', 3600);
|
|
Config::set('g7_settings.core.seo.sitemap_cache_ttl', null);
|
|
|
|
$cache = Mockery::mock(CacheInterface::class);
|
|
$cache->shouldReceive('forget')->once()->with('seo.sitemap');
|
|
$cache->shouldReceive('put')
|
|
->once()
|
|
->with(SitemapManager::META_CACHE_KEY, Mockery::type('array'), 3600);
|
|
|
|
$this->makeManager(['/a'], $cache)->regenerate();
|
|
}
|
|
|
|
/**
|
|
* regenerate: SEO 탭 지정이 없으면 고급 탭(cache.seo_sitemap_ttl) 값을 ttl 로 쓴다 (D19)
|
|
*/
|
|
public function test_regenerate_uses_advanced_tab_ttl_when_seo_override_is_unset(): void
|
|
{
|
|
Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200);
|
|
Config::set('g7_settings.core.seo.sitemap_cache_ttl', null);
|
|
|
|
$result = $this->makeManager()->regenerate();
|
|
|
|
$this->assertSame(7200, $result['data']['ttl']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: SEO 탭에 지정이 있으면 그 값이 고급 탭을 오버라이드한다 (D19)
|
|
*/
|
|
public function test_regenerate_uses_seo_override_ttl_when_set(): void
|
|
{
|
|
Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200);
|
|
Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600);
|
|
|
|
$result = $this->makeManager()->regenerate();
|
|
|
|
$this->assertSame(3600, $result['data']['ttl'], 'SEO 탭 지정이 고급 탭보다 우선해야 합니다.');
|
|
}
|
|
|
|
/**
|
|
* regenerate: last_updated_at 을 seo 설정 카테고리에 기록한다
|
|
*/
|
|
public function test_regenerate_records_last_updated_at_in_settings(): void
|
|
{
|
|
$config = Mockery::mock(ConfigRepositoryInterface::class);
|
|
$config->shouldReceive('getCategory')->once()->with('seo')->andReturn(['existing' => 'keep']);
|
|
$config->shouldReceive('saveCategory')
|
|
->once()
|
|
->with('seo', Mockery::on(function (array $saved): bool {
|
|
// 기존 키를 보존하면서 타임스탬프만 추가해야 한다
|
|
return $saved['existing'] === 'keep'
|
|
&& ! empty($saved['sitemap_last_updated_at']);
|
|
}));
|
|
|
|
$result = $this->makeManager(['/a'], null, $config)->regenerate();
|
|
|
|
$this->assertTrue($result['success']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 설정 기록이 실패해도 생성 자체는 성공으로 유지된다
|
|
*/
|
|
public function test_regenerate_survives_settings_write_failure(): void
|
|
{
|
|
$config = Mockery::mock(ConfigRepositoryInterface::class);
|
|
$config->shouldReceive('getCategory')->andThrow(new \RuntimeException('설정 저장소 장애'));
|
|
|
|
$result = $this->makeManager(['/a'], null, $config)->regenerate();
|
|
|
|
$this->assertTrue($result['success'], 'last_updated_at 기록 실패가 sitemap 생성을 무효화하면 안 됩니다.');
|
|
$this->assertSame('updated', $result['status']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 성공 시 before/after_regenerate 훅이 순서대로 발화한다
|
|
*/
|
|
public function test_regenerate_fires_before_and_after_hooks_in_order(): void
|
|
{
|
|
$order = [];
|
|
HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$order) {
|
|
$order[] = 'before';
|
|
});
|
|
HookManager::addAction('core.seo.sitemap.after_regenerate', function () use (&$order) {
|
|
$order[] = 'after';
|
|
});
|
|
|
|
$this->makeManager()->regenerate();
|
|
|
|
$this->assertSame(['before', 'after'], $order);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 실패 시 failed 를 반환하고 after_regenerate_failed 훅이 발화한다
|
|
*
|
|
* 이 훅은 확장이 구독하는 공개 표면이므로 발화 여부와 페이로드 형태를 고정한다.
|
|
*/
|
|
public function test_regenerate_fires_after_regenerate_failed_hook_on_failure(): void
|
|
{
|
|
$payloads = [];
|
|
HookManager::addAction('core.seo.sitemap.after_regenerate_failed', function ($result) use (&$payloads) {
|
|
$payloads[] = $result;
|
|
});
|
|
HookManager::addAction('core.seo.sitemap.after_regenerate', function () {
|
|
throw new \LogicException('성공 훅이 발화하면 안 됩니다.');
|
|
});
|
|
|
|
// 생성 도중 예외를 던지는 generator 로 실패를 강제한다.
|
|
$generator = Mockery::mock(SitemapGenerator::class);
|
|
$generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('디스크 장애'));
|
|
|
|
$manager = new SitemapManager(
|
|
$generator,
|
|
Mockery::spy(CacheInterface::class),
|
|
Mockery::spy(ConfigRepositoryInterface::class),
|
|
$this->storage,
|
|
);
|
|
|
|
$result = $manager->regenerate();
|
|
|
|
$this->assertFalse($result['success']);
|
|
$this->assertSame('failed', $result['status']);
|
|
$this->assertStringContainsString('디스크 장애', $result['message']);
|
|
|
|
$this->assertCount(1, $payloads, 'after_regenerate_failed 훅이 정확히 1회 발화해야 합니다.');
|
|
$this->assertSame('failed', $payloads[0]['status']);
|
|
$this->assertFalse($payloads[0]['success']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: 실패 시 캐시를 갱신하지 않는다 (구 세트 유지)
|
|
*/
|
|
public function test_regenerate_does_not_touch_cache_on_failure(): void
|
|
{
|
|
$cache = Mockery::mock(CacheInterface::class);
|
|
$cache->shouldNotReceive('put');
|
|
$cache->shouldNotReceive('forget');
|
|
|
|
$generator = Mockery::mock(SitemapGenerator::class);
|
|
$generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('장애'));
|
|
|
|
$manager = new SitemapManager(
|
|
$generator,
|
|
$cache,
|
|
Mockery::spy(ConfigRepositoryInterface::class),
|
|
$this->storage,
|
|
);
|
|
|
|
$this->assertFalse($manager->regenerate()['success']);
|
|
}
|
|
|
|
/**
|
|
* regenerate: seo.sitemap_urls_per_file 설정이 자식 분할에 반영된다
|
|
*/
|
|
public function test_regenerate_applies_urls_per_file_setting(): void
|
|
{
|
|
Config::set('g7_settings.core.seo.sitemap_urls_per_file', 2);
|
|
|
|
// 정적 라우트 + 기여자 4건 → 파일당 2건이면 자식이 2개 이상으로 나뉜다
|
|
$result = $this->makeManager(['/a', '/b', '/c', '/d'])->regenerate();
|
|
|
|
$this->assertGreaterThan(1, $result['data']['child_count'], 'urls_per_file 설정이 분할에 반영돼야 합니다.');
|
|
}
|
|
|
|
/**
|
|
* regenerate: seo.sitemap_gzip 설정이 커밋 파일명에 반영된다
|
|
*/
|
|
public function test_regenerate_applies_gzip_setting(): void
|
|
{
|
|
Config::set('g7_settings.core.seo.sitemap_gzip', true);
|
|
|
|
$this->makeManager(['/a'])->regenerate();
|
|
|
|
$manifest = (new SitemapFileStore($this->storage))->getManifest();
|
|
|
|
$this->assertStringEndsWith('.xml.gz', $manifest['children'][0]['file']);
|
|
}
|
|
|
|
/**
|
|
* getStatus: 기록된 last_updated_at 을 반환하고, 미기록 시 null 을 반환한다
|
|
*/
|
|
public function test_get_status_returns_last_updated_at_or_null(): void
|
|
{
|
|
$manager = $this->makeManager();
|
|
|
|
Config::set('g7_settings.core.seo.sitemap_last_updated_at', '');
|
|
$this->assertNull($manager->getStatus()['last_updated_at']);
|
|
|
|
Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-07-16T00:00:00+09:00');
|
|
$this->assertSame('2026-07-16T00:00:00+09:00', $manager->getStatus()['last_updated_at']);
|
|
}
|
|
}
|