대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
278 lines
8.5 KiB
PHP
278 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Extension\Storage\CoreStorageDriver;
|
|
use App\Seo\SitemapFileStore;
|
|
use App\Seo\SitemapWriter;
|
|
use App\Seo\SitemapXmlRenderer;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* SitemapFileStore 단위 테스트
|
|
*
|
|
* 읽기측 API(exists/getManifest/indexResponse/childResponse/lastGeneratedAt)와
|
|
* 경로 SSoT(childFilename/childUrl)를 검증합니다. 이 API 는 sitemap 라우트가
|
|
* 그대로 소비하는 표면이므로 단위 수준에서 계약을 고정합니다.
|
|
*/
|
|
class SitemapFileStoreTest extends TestCase
|
|
{
|
|
private CoreStorageDriver $storage;
|
|
|
|
private SitemapFileStore $store;
|
|
|
|
/**
|
|
* 테스트 초기화 - 가짜 로컬 디스크를 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Config::set('app.locale', 'ko');
|
|
Config::set('app.supported_locales', ['ko']);
|
|
|
|
Storage::fake('local');
|
|
|
|
$this->storage = new CoreStorageDriver('local');
|
|
$this->store = new SitemapFileStore($this->storage);
|
|
}
|
|
|
|
/**
|
|
* 커밋된 sitemap 세트를 생성합니다.
|
|
*
|
|
* @param int $urlCount 생성할 URL 수
|
|
* @param int $urlsPerFile 파일당 URL 수
|
|
* @param bool $gzip gzip 압축 여부
|
|
* @return array 커밋 결과 메타
|
|
*/
|
|
private function commitSet(int $urlCount = 3, int $urlsPerFile = 10, bool $gzip = false): array
|
|
{
|
|
$writer = new SitemapWriter(
|
|
$this->storage,
|
|
SitemapXmlRenderer::fromConfig(),
|
|
$urlsPerFile,
|
|
$gzip,
|
|
);
|
|
|
|
$writer->open();
|
|
for ($i = 1; $i <= $urlCount; $i++) {
|
|
// writer 는 절대 URL(loc) 을 받는다 — 상대 경로(url) 는 Generator 가 정규화하는 형식이다.
|
|
$writer->addUrl(['loc' => url("/page/{$i}")]);
|
|
}
|
|
$writer->close();
|
|
|
|
return $writer->commit();
|
|
}
|
|
|
|
/**
|
|
* StreamedResponse 본문을 문자열로 수집합니다.
|
|
*
|
|
* @param StreamedResponse $response 스트리밍 응답
|
|
* @return string 응답 본문
|
|
*/
|
|
private function streamBody(StreamedResponse $response): string
|
|
{
|
|
ob_start();
|
|
$response->sendContent();
|
|
|
|
return (string) ob_get_clean();
|
|
}
|
|
|
|
/**
|
|
* childFilename: 자식 파일명 규칙이 고정된다 (경로 SSoT)
|
|
*/
|
|
public function test_child_filename_follows_naming_rule(): void
|
|
{
|
|
$this->assertSame('sitemap-1.xml', SitemapFileStore::childFilename(1));
|
|
$this->assertSame('sitemap-7.xml', SitemapFileStore::childFilename(7));
|
|
$this->assertSame('sitemap-1.xml.gz', SitemapFileStore::childFilename(1, true));
|
|
}
|
|
|
|
/**
|
|
* childUrl: 자식 sitemap 의 공개 절대 URL 을 반환한다
|
|
*/
|
|
public function test_child_url_returns_absolute_url(): void
|
|
{
|
|
$this->assertSame(url('/sitemap-1.xml'), SitemapFileStore::childUrl(1));
|
|
$this->assertSame(url('/sitemap-2.xml.gz'), SitemapFileStore::childUrl(2, true));
|
|
}
|
|
|
|
/**
|
|
* exists: 커밋된 세트가 없으면 false
|
|
*/
|
|
public function test_exists_returns_false_without_committed_set(): void
|
|
{
|
|
$this->assertFalse($this->store->exists());
|
|
}
|
|
|
|
/**
|
|
* exists: 커밋 후 true
|
|
*/
|
|
public function test_exists_returns_true_after_commit(): void
|
|
{
|
|
$this->commitSet();
|
|
|
|
$this->assertTrue($this->store->exists());
|
|
}
|
|
|
|
/**
|
|
* getManifest: 세트가 없으면 null
|
|
*/
|
|
public function test_get_manifest_returns_null_without_committed_set(): void
|
|
{
|
|
$this->assertNull($this->store->getManifest());
|
|
}
|
|
|
|
/**
|
|
* getManifest: 커밋된 메타를 반환한다
|
|
*/
|
|
public function test_get_manifest_returns_committed_metadata(): void
|
|
{
|
|
$this->commitSet(urlCount: 3);
|
|
|
|
$manifest = $this->store->getManifest();
|
|
|
|
$this->assertIsArray($manifest);
|
|
$this->assertSame(3, $manifest['url_count']);
|
|
$this->assertSame(1, $manifest['child_count']);
|
|
$this->assertCount(1, $manifest['children']);
|
|
$this->assertSame('sitemap-1.xml', $manifest['children'][0]['file']);
|
|
}
|
|
|
|
/**
|
|
* getManifest: manifest 가 손상되면 null 을 반환한다 (예외 전파 금지)
|
|
*/
|
|
public function test_get_manifest_returns_null_when_manifest_is_corrupted(): void
|
|
{
|
|
$this->commitSet();
|
|
$this->storage->put(
|
|
SitemapFileStore::CATEGORY,
|
|
SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE,
|
|
'{ this is not json',
|
|
);
|
|
|
|
$this->assertNull($this->store->getManifest());
|
|
}
|
|
|
|
/**
|
|
* indexResponse: 세트가 없으면 null
|
|
*/
|
|
public function test_index_response_returns_null_without_committed_set(): void
|
|
{
|
|
$this->assertNull($this->store->indexResponse());
|
|
}
|
|
|
|
/**
|
|
* indexResponse: sitemapindex 를 XML Content-Type 으로 스트리밍한다
|
|
*/
|
|
public function test_index_response_streams_sitemap_index(): void
|
|
{
|
|
$this->commitSet(urlCount: 12, urlsPerFile: 10);
|
|
|
|
$response = $this->store->indexResponse();
|
|
|
|
$this->assertInstanceOf(StreamedResponse::class, $response);
|
|
$this->assertSame('application/xml', $response->headers->get('Content-Type'));
|
|
|
|
$body = $this->streamBody($response);
|
|
$this->assertStringContainsString('<sitemapindex', $body);
|
|
$this->assertStringContainsString(url('/sitemap-1.xml'), $body);
|
|
$this->assertStringContainsString(url('/sitemap-2.xml'), $body);
|
|
}
|
|
|
|
/**
|
|
* childResponse: 세트가 없으면 null
|
|
*/
|
|
public function test_child_response_returns_null_without_committed_set(): void
|
|
{
|
|
$this->assertNull($this->store->childResponse(1));
|
|
}
|
|
|
|
/**
|
|
* childResponse: 자식 sitemap 본문을 스트리밍한다
|
|
*/
|
|
public function test_child_response_streams_child_sitemap(): void
|
|
{
|
|
$this->commitSet(urlCount: 2);
|
|
|
|
$response = $this->store->childResponse(1);
|
|
|
|
$this->assertInstanceOf(StreamedResponse::class, $response);
|
|
$this->assertSame('application/xml', $response->headers->get('Content-Type'));
|
|
$this->assertNull($response->headers->get('Content-Encoding'));
|
|
|
|
$body = $this->streamBody($response);
|
|
$this->assertStringContainsString('<urlset', $body);
|
|
$this->assertStringContainsString(url('/page/1'), $body);
|
|
}
|
|
|
|
/**
|
|
* childResponse: 존재하지 않는 자식 번호는 null
|
|
*/
|
|
public function test_child_response_returns_null_for_out_of_range_number(): void
|
|
{
|
|
$this->commitSet(urlCount: 2, urlsPerFile: 10);
|
|
|
|
$this->assertNull($this->store->childResponse(2), '자식이 1개뿐이면 2번은 없어야 합니다.');
|
|
}
|
|
|
|
/**
|
|
* childResponse: 0 이하 번호는 null (경로 조작 방어)
|
|
*/
|
|
public function test_child_response_returns_null_for_non_positive_number(): void
|
|
{
|
|
$this->commitSet();
|
|
|
|
$this->assertNull($this->store->childResponse(0));
|
|
$this->assertNull($this->store->childResponse(-1));
|
|
}
|
|
|
|
/**
|
|
* childResponse: gzip 세트는 Content-Encoding 헤더를 붙인다
|
|
*/
|
|
public function test_child_response_sets_gzip_content_encoding(): void
|
|
{
|
|
$this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true);
|
|
|
|
$response = $this->store->childResponse(1);
|
|
|
|
$this->assertInstanceOf(StreamedResponse::class, $response);
|
|
$this->assertSame('gzip', $response->headers->get('Content-Encoding'));
|
|
}
|
|
|
|
/**
|
|
* indexResponse: gzip 세트여도 인덱스는 비압축이다 (D13)
|
|
*/
|
|
public function test_index_response_is_never_gzip_encoded(): void
|
|
{
|
|
$this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true);
|
|
|
|
$response = $this->store->indexResponse();
|
|
|
|
$this->assertInstanceOf(StreamedResponse::class, $response);
|
|
$this->assertNull($response->headers->get('Content-Encoding'));
|
|
}
|
|
|
|
/**
|
|
* lastGeneratedAt: 세트가 없으면 null
|
|
*/
|
|
public function test_last_generated_at_returns_null_without_committed_set(): void
|
|
{
|
|
$this->assertNull($this->store->lastGeneratedAt());
|
|
}
|
|
|
|
/**
|
|
* lastGeneratedAt: manifest 의 생성 시각을 반환한다
|
|
*/
|
|
public function test_last_generated_at_returns_manifest_timestamp(): void
|
|
{
|
|
$meta = $this->commitSet();
|
|
|
|
$this->assertNotNull($this->store->lastGeneratedAt());
|
|
$this->assertSame($meta['generated_at'], $this->store->lastGeneratedAt());
|
|
}
|
|
}
|