diff --git a/CHANGELOG.md b/CHANGELOG.md index da67a6a4..eeede335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다. - 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. - 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다. +- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다. - 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.) ### Fixed diff --git a/app/Enums/SitemapChangeFreq.php b/app/Enums/SitemapChangeFreq.php new file mode 100644 index 00000000..28c2046c --- /dev/null +++ b/app/Enums/SitemapChangeFreq.php @@ -0,0 +1,70 @@ + 값 Enum (sitemaps.org 폐쇄 어휘) + * + * https://www.sitemaps.org/protocol.html 의 는 크롤러에게 페이지 갱신 + * 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며, + * 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더 + * (SitemapXmlRenderer) 두 경계에서 차단한다. + * + * 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해 + * 오타를 작성 시점에 잡는다. + */ +enum SitemapChangeFreq: string +{ + /** + * 접근할 때마다 변경 (예: 실시간 시세) + */ + case Always = 'always'; + + /** + * 시간 단위 변경 + */ + case Hourly = 'hourly'; + + /** + * 일 단위 변경 + */ + case Daily = 'daily'; + + /** + * 주 단위 변경 + */ + case Weekly = 'weekly'; + + /** + * 월 단위 변경 + */ + case Monthly = 'monthly'; + + /** + * 연 단위 변경 + */ + case Yearly = 'yearly'; + + /** + * 사실상 변경 없음 (예: 보관 URL) + */ + case Never = 'never'; + + /** + * 임의 문자열을 유효한 changefreq 값으로 정규화합니다. + * + * 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려 + * 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다. + * + * @param string|null $value 검증할 원본 값 + * @return string|null 유효하면 정규화된 값, 아니면 null + */ + public static function normalize(?string $value): ?string + { + if ($value === null || trim($value) === '') { + return null; + } + + return self::tryFrom(strtolower(trim($value)))?->value; + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index ff747280..bfc9cb6d 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -2,6 +2,7 @@ namespace App\Seo; +use App\Enums\SitemapChangeFreq; use App\Extension\TemplateManager; use App\Seo\Contracts\SitemapContributorInterface; use App\Services\TemplateService; @@ -290,7 +291,7 @@ class SitemapGenerator $urls[] = [ 'loc' => url($routePath), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.5, ]; } diff --git a/app/Seo/SitemapIndexer.php b/app/Seo/SitemapIndexer.php index b30615fe..cccec0db 100644 --- a/app/Seo/SitemapIndexer.php +++ b/app/Seo/SitemapIndexer.php @@ -3,6 +3,7 @@ namespace App\Seo; use App\Contracts\Repositories\SitemapUrlRepositoryInterface; +use App\Enums\SitemapChangeFreq; use App\Extension\HookManager; /** @@ -63,7 +64,8 @@ class SitemapIndexer $normalized[] = [ 'loc' => $loc, 'lastmod' => $entry['lastmod'] ?? null, - 'changefreq' => $entry['changefreq'] ?? null, + // 폐쇄 어휘로 정규화 — 비표준 값이 sitemap_urls 에 저장되는 것을 차단. + 'changefreq' => SitemapChangeFreq::normalize($entry['changefreq'] ?? null), 'priority' => $entry['priority'] ?? null, 'contributor' => $contributor, ]; diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php index ba3952d8..dd269937 100644 --- a/app/Seo/SitemapXmlRenderer.php +++ b/app/Seo/SitemapXmlRenderer.php @@ -2,6 +2,7 @@ namespace App\Seo; +use App\Enums\SitemapChangeFreq; use Illuminate\Support\Facades\Log; /** @@ -270,8 +271,10 @@ final class SitemapXmlRenderer $xml .= ' '.$this->escape((string) $entry['lastmod']).''."\n"; } - if (! empty($entry['changefreq'])) { - $xml .= ' '.$this->escape((string) $entry['changefreq']).''."\n"; + // changefreq 는 sitemaps.org 폐쇄 어휘 — Enum 으로 정규화해 비표준 값은 출력하지 않는다. + $changefreq = SitemapChangeFreq::normalize($entry['changefreq'] ?? null); + if ($changefreq !== null) { + $xml .= ' '.$changefreq.''."\n"; } if (isset($entry['priority'])) { diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index fb28f1e4..07d7b52a 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -612,6 +612,10 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor `SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다. +#### changefreq 는 `App\Enums\SitemapChangeFreq` (폐쇄 어휘) + +entry 의 `changefreq` 는 sitemaps.org 의 폐쇄 어휘(`always`/`hourly`/`daily`/`weekly`/`monthly`/`yearly`/`never`)로, `App\Enums\SitemapChangeFreq` 가 SSoT 입니다. 기여자·리스너·정적 URL 은 리터럴 문자열 대신 `SitemapChangeFreq::Weekly->value` 처럼 case 값을 사용해 오타를 작성 시점에 잡습니다. 규격에 없는 값은 저장 경계(`SitemapIndexer`)와 렌더 경계(`SitemapXmlRenderer`)에서 `SitemapChangeFreq::normalize()` 로 정규화되어 `null` 로 떨어지므로 사이트맵 XML 에 비표준 값이 출력되지 않습니다(대소문자·앞뒤 공백은 흡수). 제3자 raw 구현체가 임의 문자열을 emit 해도 이 두 경계에서 안전하게 걸러집니다. + ### ServiceProvider 등록 ```php diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php index ae6b0308..c964da6f 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; @@ -185,7 +186,7 @@ class SeoBoardCacheListener implements HookListenerInterface $indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[ 'url' => "/board/{$slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, ]]); } else { @@ -226,7 +227,7 @@ class SeoBoardCacheListener implements HookListenerInterface $indexer->indexResource('board', $board->id, 'sirsoft-board', [[ 'url' => "/board/{$board->slug}", 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.6, ]]); } else { diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 15a48832..952cbe11 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Board\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -82,7 +83,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor if ($seoBoards) { yield [ 'url' => '/boards', - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.5, 'resource_type' => 'board_index', 'resource_id' => null, @@ -100,7 +101,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/board/{$board->slug}", 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.6, 'resource_type' => 'board', 'resource_id' => (string) $board->id, @@ -116,7 +117,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/board/{$board->slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, 'resource_type' => 'board_post', 'resource_id' => (string) $post->id, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php index 99f22ec4..e43aba68 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SitemapIndexer; @@ -145,7 +146,7 @@ class SeoCategoryCacheListener implements HookListenerInterface $indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.6, ]]); } else { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php index ca75d822..97ad9d90 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; @@ -163,7 +164,7 @@ class SeoProductCacheListener implements HookListenerInterface $indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.8, ]]); } else { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index 22951383..c4c5b996 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; @@ -79,7 +80,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) { yield [ 'url' => "/{$routePath}/products", - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.7, 'resource_type' => 'shop_index', 'resource_id' => null, @@ -92,7 +93,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.6, 'resource_type' => 'category', 'resource_id' => (string) $category->id, @@ -106,7 +107,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.8, 'resource_type' => 'product', 'resource_id' => (string) $product->id, diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php index 3fa39300..c333e40d 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Page\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SitemapIndexer; @@ -137,7 +138,7 @@ class SeoPageCacheListener implements HookListenerInterface $indexer->indexResource('page', $page->id, 'sirsoft-page', [[ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, ]]); } else { diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index bdf87c17..bb4d2116 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Page\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; @@ -60,7 +61,7 @@ class PageSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, 'resource_type' => 'page', 'resource_id' => (string) $page->id, diff --git a/tests/Unit/Enums/SitemapChangeFreqTest.php b/tests/Unit/Enums/SitemapChangeFreqTest.php new file mode 100644 index 00000000..739a330a --- /dev/null +++ b/tests/Unit/Enums/SitemapChangeFreqTest.php @@ -0,0 +1,57 @@ + $c->value, SitemapChangeFreq::cases()); + + $this->assertSame( + ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'], + $values + ); + } + + /** + * 유효한 값은 그대로 정규화된다. + */ + public function test_normalize_returns_valid_value(): void + { + $this->assertSame('daily', SitemapChangeFreq::normalize('daily')); + $this->assertSame('monthly', SitemapChangeFreq::normalize('monthly')); + } + + /** + * 대소문자·앞뒤 공백은 흡수한다. + */ + public function test_normalize_absorbs_case_and_whitespace(): void + { + $this->assertSame('weekly', SitemapChangeFreq::normalize('WEEKLY')); + $this->assertSame('weekly', SitemapChangeFreq::normalize(' Weekly ')); + } + + /** + * 폐쇄 어휘에 없는 값·빈 값·null 은 null 로 떨어진다 (XML 유입 차단). + */ + public function test_normalize_rejects_invalid_and_empty(): void + { + $this->assertNull(SitemapChangeFreq::normalize('biweekly')); + $this->assertNull(SitemapChangeFreq::normalize('yes')); + $this->assertNull(SitemapChangeFreq::normalize('')); + $this->assertNull(SitemapChangeFreq::normalize(' ')); + $this->assertNull(SitemapChangeFreq::normalize(null)); + } +} diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index 64bb60ed..d3822835 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Notifications; +use App\Contracts\Notifications\ChannelReadinessCheckerInterface; use App\Models\NotificationDefinition; use App\Models\NotificationTemplate; use App\Models\User; @@ -54,6 +55,12 @@ class GenericNotificationViaTest extends TestCase // 기본: 모든 채널을 활성으로 mocking (공유 storage/app/settings/notifications.json의 // 실제 값과 격리). 개별 테스트에서 mockChannelEnabled(['xxx' => false])로 재정의 가능. $this->mockChannelEnabled([]); + + // 기본: 모든 채널을 readiness OK로 mocking. mail readiness 는 실제 settings 디스크의 + // mail.json(from_address/SMTP 등)에 의존하는데, TestCase 전역 settings fake 로 비어 있어 + // 실 환경에서는 항상 not-ready 로 떨어진다. via() 채널 결정 로직만 결정적으로 검증하기 위해 + // readiness 게이트를 mock 으로 격리한다. readiness 자체는 ChannelReadinessServiceTest 가 검증. + $this->mockReadiness([]); } protected function tearDown(): void @@ -157,7 +164,7 @@ class GenericNotificationViaTest extends TestCase /** * NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking. * - * @param array $channelMap + * @param array $channelMap */ private function mockChannelEnabled(array $channelMap): void { @@ -169,6 +176,27 @@ class GenericNotificationViaTest extends TestCase $this->app->instance(NotificationChannelService::class, $mock); } + /** + * ChannelReadinessCheckerInterface::isReady()/check() 응답을 mocking. + * + * 미지정 채널은 ready=true 기본. 개별 테스트에서 mockReadiness(['mail' => false]) 로 + * 특정 채널을 not-ready 로 재정의할 수 있다. + * + * @param array $readyMap 채널별 readiness 맵 + */ + private function mockReadiness(array $readyMap): void + { + $mock = Mockery::mock(ChannelReadinessCheckerInterface::class); + $mock->shouldReceive('isReady') + ->andReturnUsing(fn ($channel) => $readyMap[$channel] ?? true); + $mock->shouldReceive('check') + ->andReturnUsing(fn ($channel) => [ + 'ready' => $readyMap[$channel] ?? true, + 'reason' => ($readyMap[$channel] ?? true) ? null : 'notification.readiness.mail_from_address_not_configured', + ]); + $this->app->instance(ChannelReadinessCheckerInterface::class, $mock); + } + /** * 양쪽 채널 모두 활성 템플릿이 있으면 두 채널 모두 반환 */ @@ -185,6 +213,23 @@ class GenericNotificationViaTest extends TestCase $this->assertCount(2, $channels); } + /** + * readiness 실패 채널은 제외 — 템플릿·확장 토글이 OK여도 미설정 mail 은 발송 대상에서 빠진다. + */ + public function test_via_excludes_channel_that_is_not_ready(): void + { + $this->createTemplate('mail'); + $this->createTemplate('database'); + + $this->mockReadiness(['mail' => false, 'database' => true]); + + $notification = new GenericNotification('test_via_check', 'core.test'); + $channels = $notification->via($this->user); + + $this->assertNotContains('mail', $channels); + $this->assertContains('database', $channels); + } + /** * mail 템플릿만 있으면 mail만 반환, database 제외 */ @@ -257,10 +302,6 @@ class GenericNotificationViaTest extends TestCase /** * 테스트용 템플릿 생성 헬퍼 - * - * @param string $channel - * @param bool $isActive - * @return NotificationTemplate */ private function createTemplate(string $channel, bool $isActive = true): NotificationTemplate { diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php index eb17528f..86f4b011 100644 --- a/tests/Unit/Seo/SitemapXmlRendererTest.php +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -336,18 +336,45 @@ class SitemapXmlRendererTest extends TestCase } /** - * urlBlock: 메타 필드의 XML 특수문자도 이스케이프한다 + * urlBlock: 자유 형식 메타 필드(lastmod)의 XML 특수문자를 이스케이프한다 + * + * changefreq 는 폐쇄 어휘(SitemapChangeFreq)로 정규화되므로 특수문자가 유입될 수 없다 + * (아래 test_url_block_omits_invalid_changefreq 로 별도 검증). */ public function test_url_block_escapes_special_characters_in_meta_fields(): void { $xml = $this->singleLocale()->urlBlock([ 'loc' => 'https://example.test/a', 'lastmod' => '2026 & later', - 'changefreq' => 'daassertStringContainsString('2026 & later', $xml); - $this->assertStringContainsString('da<ily', $xml); + } + + /** + * urlBlock: 폐쇄 어휘(sitemaps.org)에 없는 changefreq 는 태그를 생략한다 (비표준 값 유입 차단) + */ + public function test_url_block_omits_invalid_changefreq(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => 'biweekly', + ]); + + $this->assertStringNotContainsString('', $xml); + } + + /** + * urlBlock: changefreq 대소문자·공백을 정규화해 표준 소문자로 출력한다 + */ + public function test_url_block_normalizes_changefreq_casing(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => ' WEEKLY ', + ]); + + $this->assertStringContainsString('weekly', $xml); } /** diff --git a/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php b/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php index d6ab0e25..7b342ec6 100644 --- a/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php +++ b/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php @@ -64,20 +64,32 @@ class Upgrade_7_0_0_beta_3Test extends TestCase } /** - * config/app.php 의 restore_ownership_group_writable 기본값과도 동일해야 한다. + * 동결된 TARGETS 는 살아있는 config 기본값과 의도적으로 갈라진다 (동결 규정 §7). + * + * beta.3 스텝은 실행 당시의 동작을 100% 보존해야 하므로 TARGETS 는 동결되어 있다. + * 반면 config('app.update.restore_ownership_group_writable') 기본값은 #326 에서 + * storage/app 사용자 데이터 보존을 위해 storage → storage/logs + storage/framework 로 + * 세분화되고 lang-packs 등이 추가되며 정당하게 진화했다. + * + * 따라서 둘은 같을 수 없고 같아서도 안 된다. 이 테스트는 누군가 동결 스텝의 TARGETS 를 + * 현재 config 에 맞추려 편집하는 동결 위반(또는 config 원복)을 차단한다. */ - public function test_targets_matches_config_app_restore_ownership_group_writable(): void + public function test_frozen_targets_intentionally_diverges_from_evolving_config(): void { - $configDefault = config('app.update.restore_ownership_group_writable'); + $liveConfigDefault = array_values(config('app.update.restore_ownership_group_writable')); $reflection = new \ReflectionClassConstant(Upgrade_7_0_0_beta_3::class, 'TARGETS'); $targets = $reflection->getValue(); - $this->assertSame( + $this->assertNotSame( + $liveConfigDefault, $targets, - array_values($configDefault), - 'TARGETS 상수와 config 기본값이 1:1 동일해야 한다' + '동결 TARGETS 가 살아있는 config 와 동일해졌다면 동결 규정 위반이거나 config 가 되돌려진 것이다' ); + + // 동결 스텝은 세분화 이전 — storage 를 통째로 다뤘다. config 는 #326 에서 세분화됨. + $this->assertContains('storage', $targets, '동결 TARGETS 는 세분화 이전의 storage 전체 항목을 유지한다'); + $this->assertNotContains('storage/logs', $targets, '동결 TARGETS 에는 #326 의 세분화 항목이 유입되면 안 된다'); } /**