feat(seo): changefreq Enum 도입 + 사이트맵 사전결함 테스트 정정
계획서 위임 항목(Q6·Q7·Q8) 마감. Q8: sitemaps.org 폐쇄 어휘를 App\Enums\SitemapChangeFreq 로 도입해 저장(SitemapIndexer)·렌더(SitemapXmlRenderer) 두 경계에서 normalize 하고, 생산 지점(생성기 정적 URL·3 contributor·4 리스너) 리터럴을 enum case 로 치환. 규격에 없는 changefreq 가 사이트맵에 그대로 출력되던 것을 차단(대소문자·공백 흡수). Q6: Upgrade_7_0_0_beta_3Test 의 "동결 TARGETS === 살아있는 config" 단언은 동결 규정과 충돌하는 무효 불변식(config 는 에서 정당하게 진화)이므로 의도적 divergence 를 검증하도록 재작성. Q7: 코어 20건 사전 실패를 격리 실측으로 재분류 — 6클러스터는 전 스위트 OOM/싱글톤 누수 오염(격리 시 통과), GenericNotificationViaTest 는 readiness 게이트를 mock 하지 않아 빈 settings 로 mail 이 항상 제외되던 stale test 를 정정.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
|
||||
- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
|
||||
- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
|
||||
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
|
||||
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* 사이트맵 <changefreq> 값 Enum (sitemaps.org 폐쇄 어휘)
|
||||
*
|
||||
* https://www.sitemaps.org/protocol.html 의 <changefreq> 는 크롤러에게 페이지 갱신
|
||||
* 빈도를 알려주는 힌트로, 허용 값이 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use App\Enums\SitemapChangeFreq;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -270,8 +271,10 @@ final class SitemapXmlRenderer
|
||||
$xml .= ' <lastmod>'.$this->escape((string) $entry['lastmod']).'</lastmod>'."\n";
|
||||
}
|
||||
|
||||
if (! empty($entry['changefreq'])) {
|
||||
$xml .= ' <changefreq>'.$this->escape((string) $entry['changefreq']).'</changefreq>'."\n";
|
||||
// changefreq 는 sitemaps.org 폐쇄 어휘 — Enum 으로 정규화해 비표준 값은 출력하지 않는다.
|
||||
$changefreq = SitemapChangeFreq::normalize($entry['changefreq'] ?? null);
|
||||
if ($changefreq !== null) {
|
||||
$xml .= ' <changefreq>'.$changefreq.'</changefreq>'."\n";
|
||||
}
|
||||
|
||||
if (isset($entry['priority'])) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Enums;
|
||||
|
||||
use App\Enums\SitemapChangeFreq;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapChangeFreq Enum 테스트
|
||||
*
|
||||
* sitemaps.org 폐쇄 어휘(7값)와 normalize() 정규화/차단 동작을 검증합니다.
|
||||
*/
|
||||
class SitemapChangeFreqTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* sitemaps.org 의 7개 값이 모두 존재해야 한다.
|
||||
*/
|
||||
public function test_cases_cover_sitemaps_org_vocabulary(): void
|
||||
{
|
||||
$values = array_map(fn (SitemapChangeFreq $c) => $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));
|
||||
}
|
||||
}
|
||||
@@ -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<string, bool> $channelMap
|
||||
* @param array<string, bool> $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<string, bool> $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
|
||||
{
|
||||
|
||||
@@ -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' => 'da<ily',
|
||||
]);
|
||||
|
||||
$this->assertStringContainsString('<lastmod>2026 & later</lastmod>', $xml);
|
||||
$this->assertStringContainsString('<changefreq>da<ily</changefreq>', $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('<changefreq>', $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('<changefreq>weekly</changefreq>', $xml);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 의 세분화 항목이 유입되면 안 된다');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user