Files
Gnuboard7/tests/Unit/Seo/SitemapManagerTest.php
T
HeuJung face022073 feat(seo): 사이트맵 재생성 진행상황 + 실시간/폴링 UI
관리자 "전체 재생성"을 큐(Full 모드)로 던지고 진행상황을 SEO 탭에서 확인한다.
Reverb ON 이면 방송 채널 구독으로 실시간 갱신, OFF 면 상태 API 폴링으로 폴백한다.

- SitemapProgress 진행상황 스토어(queued→running→writing→completed/failed,
 캐시 TTL 3600, 방송 스로틀) + SitemapManager.getStatus 확장(progress + realtime_enabled)
- GET /api/admin/seo/sitemap/status(core.settings.read) 신규
- 관리자 재생성을 항상 Full 모드로 큐잉 + 진행률 기록, Job 최종 실패 시 progress=failed 고정
- core.admin.seo.sitemap 방송 채널 인증(core.settings.read)
- SEO 탭 진행상황 UI(상태 배지/단계/누적 URL/실시간·폴링 안내) + 폴링 분기
- 재생성 중 누적 URL 표기(replaceAllForContributor 삽입 건수 반환 — count 쿼리 없이 스트림 누적)
- ja 언어팩 진행상황 UI 번역 동기화
2026-07-18 18:22:16 +09:00

412 lines
15 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
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\Foundation\Testing\RefreshDatabase;
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
{
use RefreshDatabase;
/**
* 이 테스트가 등록·검증하는 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,
$this->app->make(SitemapUrlRepositoryInterface::class),
$this->app->make(\App\Seo\SitemapProgress::class),
);
}
/**
* 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('generateToWriterFromEntries')->andThrow(new \RuntimeException('디스크 장애'));
$manager = new SitemapManager(
$generator,
Mockery::spy(CacheInterface::class),
Mockery::spy(ConfigRepositoryInterface::class),
$this->storage,
$this->incrementalRepository(),
$this->app->make(\App\Seo\SitemapProgress::class),
);
$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('generateToWriterFromEntries')->andThrow(new \RuntimeException('장애'));
$manager = new SitemapManager(
$generator,
$cache,
Mockery::spy(ConfigRepositoryInterface::class),
$this->storage,
$this->incrementalRepository(),
$this->app->make(\App\Seo\SitemapProgress::class),
);
$this->assertFalse($manager->regenerate()['success']);
}
/**
* incremental 모드로 진입시키는(테이블이 비어있지 않은) mock 저장소를 만듭니다.
*
* countVisible()>0 → auto 가 incremental 로 해석되어 rebuildStore(전체 재적재)를 건너뛰고
* 곧바로 generateToWriterFromEntries 로 진입합니다(실패 경로 단순화).
*
* @return SitemapUrlRepositoryInterface mock 저장소
*/
private function incrementalRepository(): SitemapUrlRepositoryInterface
{
$repository = Mockery::mock(SitemapUrlRepositoryInterface::class);
$repository->shouldReceive('countVisible')->andReturn(1);
$repository->shouldReceive('streamVisible')->andReturn([]);
return $repository;
}
/**
* 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']);
}
}