feat(seo): 사이트맵 증분 저장소 + 리소스 리스너 재배선
사이트맵을 리소스 단위로 영속화(sitemap_urls)해 진정한 증분 갱신과 도메인 재쿼리 없는 파일 재작성을 구현했다. 리소스 공개/비공개/삭제 시 해당 항목만 색인이 갱신되고, 관리자=full·스케줄러=auto·리소스 변경=증분으로 동작한다. - sitemap_urls 마이그레이션 + SitemapUrl 모델. loc(2048) 를 unique 에 직접 넣으면 InnoDB 키 길이를 초과하므로 loc_hash(sha256) 컬럼을 identity 로 사용 - SitemapUrlRepository(멱등 upsert / remove / 스트리밍) + SitemapIndexer (+ 제3자용 sitemap.index.collect_for_resource 필터 훅) - 4 리스너 재배선: 공개→색인 / 비공개·삭제→색인 해제 + 재생성 잡 디바운스 디스패치. 3 기여자가 resource_type/resource_id 를 함께 emit 해 전체·증분 identity 를 일치 - 재생성 모드를 SitemapGenerationMode Backed Enum 으로 통일(Command→Job→Manager) - seo:generate-sitemap 에 --rebuild / --mode 옵션 추가
This commit is contained in:
1 parent
1a6fca0d6b
commit
1629684156
34 files changed
+2027
-23
No files matched your search
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Board\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
|
||||
/**
|
||||
* 게시글 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -65,6 +68,8 @@ class SeoBoardCacheListener implements HookListenerInterface
|
||||
|
||||
// 게시글 상세 페이지 캐시 즉시 재생성
|
||||
$this->regenerateDetailCache($args);
|
||||
|
||||
$this->syncPostSitemapIndex($args, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +83,8 @@ class SeoBoardCacheListener implements HookListenerInterface
|
||||
|
||||
// 게시글 상세 페이지 캐시 즉시 재생성
|
||||
$this->regenerateDetailCache($args);
|
||||
|
||||
$this->syncPostSitemapIndex($args, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +97,8 @@ class SeoBoardCacheListener implements HookListenerInterface
|
||||
public function onPostDelete(...$args): void
|
||||
{
|
||||
$this->invalidateRelatedCaches($args);
|
||||
|
||||
$this->syncPostSitemapIndex($args, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +150,96 @@ class SeoBoardCacheListener implements HookListenerInterface
|
||||
'board_slug' => $slug,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->syncBoardSitemapIndex($board);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글의 사이트맵 색인을 증분 갱신합니다.
|
||||
*
|
||||
* 게시 상태(Published)이고 비밀글이 아니며 'SEO 제공 페이지(게시글 상세)' 토글이 켜져 있으면
|
||||
* 색인(upsert), 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다.
|
||||
* 색인 규칙은 BoardSitemapContributor 의 게시글 URL 규칙과 일치해야 합니다.
|
||||
*
|
||||
* @param array $args 훅 인자 배열 (첫 번째: Post 모델, 두 번째: 게시판 slug)
|
||||
* @param bool $deleted 삭제 이벤트 여부
|
||||
*/
|
||||
private function syncPostSitemapIndex(array $args, bool $deleted): void
|
||||
{
|
||||
$post = $args[0] ?? null;
|
||||
$slug = $args[1] ?? null;
|
||||
|
||||
if (! $post || ! isset($post->id) || ! $slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexer = app(SitemapIndexer::class);
|
||||
|
||||
$visible = ! $deleted
|
||||
&& ($post->status ?? null) === PostStatus::Published
|
||||
&& ! (bool) ($post->is_secret ?? false)
|
||||
&& (bool) g7_module_settings('sirsoft-board', 'seo.seo_post_detail', true);
|
||||
|
||||
if ($visible) {
|
||||
$indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[
|
||||
'url' => "/board/{$slug}/{$post->id}",
|
||||
'lastmod' => $post->updated_at?->toW3cString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => 0.5,
|
||||
]]);
|
||||
} else {
|
||||
$indexer->deindexResource('board_post', $post->id);
|
||||
}
|
||||
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Board post sitemap index sync failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'post_id' => is_object($post) ? ($post->id ?? null) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판의 사이트맵 색인을 증분 갱신합니다.
|
||||
*
|
||||
* 활성(is_active) 이며 'SEO 제공 페이지(게시판)' 토글이 켜져 있으면 색인(upsert),
|
||||
* 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다.
|
||||
* 색인 규칙은 BoardSitemapContributor 의 게시판 URL 규칙과 일치해야 합니다.
|
||||
*
|
||||
* @param mixed $board Board 모델
|
||||
*/
|
||||
private function syncBoardSitemapIndex(mixed $board): void
|
||||
{
|
||||
if (! is_object($board) || ! isset($board->id) || ! isset($board->slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexer = app(SitemapIndexer::class);
|
||||
|
||||
$visible = (bool) ($board->is_active ?? false)
|
||||
&& (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true);
|
||||
|
||||
if ($visible) {
|
||||
$indexer->indexResource('board', $board->id, 'sirsoft-board', [[
|
||||
'url' => "/board/{$board->slug}",
|
||||
'lastmod' => $board->updated_at?->toW3cString(),
|
||||
'changefreq' => 'daily',
|
||||
'priority' => 0.6,
|
||||
]]);
|
||||
} else {
|
||||
$indexer->deindexResource('board', $board->id);
|
||||
}
|
||||
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Board sitemap index sync failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'board_id' => is_object($board) ? ($board->id ?? null) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,6 +84,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor
|
||||
'url' => '/boards',
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => 0.5,
|
||||
'resource_type' => 'board_index',
|
||||
'resource_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -100,6 +102,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor
|
||||
'lastmod' => $board->updated_at?->toW3cString(),
|
||||
'changefreq' => 'daily',
|
||||
'priority' => 0.6,
|
||||
'resource_type' => 'board',
|
||||
'resource_id' => (string) $board->id,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -114,6 +118,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor
|
||||
'lastmod' => $post->updated_at?->toW3cString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => 0.5,
|
||||
'resource_type' => 'board_post',
|
||||
'resource_id' => (string) $post->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Seo;
|
||||
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Models\SitemapUrl;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* Board 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
|
||||
*
|
||||
* 검증 목적 (DoD: 공개→append / 비공개→remove):
|
||||
* - 게시(Published) + 비밀글 아님 → 색인, 블라인드/비밀글/삭제 → 색인 제거
|
||||
* - 활성 게시판 수정 → 색인, 비활성 → 색인 제거
|
||||
*/
|
||||
class BoardSitemapIndexTest extends ModuleTestCase
|
||||
{
|
||||
private SeoBoardCacheListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 캐시 무효화/재생성은 이 테스트 범위 밖 — spy 로 대체
|
||||
$this->app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class));
|
||||
$this->app->instance(SeoCacheRegenerator::class, Mockery::spy(SeoCacheRegenerator::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoBoardCacheListener;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시(Published)·비밀글 아님 게시글 생성 시 색인(append)된다.
|
||||
*/
|
||||
public function test_published_public_post_is_indexed(): void
|
||||
{
|
||||
$post = (object) ['id' => 100, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()];
|
||||
|
||||
$this->listener->onPostCreate($post, 'notice');
|
||||
|
||||
$row = SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '100')->first();
|
||||
$this->assertNotNull($row, '공개 게시글은 색인돼야 합니다.');
|
||||
$this->assertStringEndsWith('/board/notice/100', $row->loc);
|
||||
Bus::assertDispatched(GenerateSitemapJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글은 색인되지 않는다(전시 상태여도).
|
||||
*/
|
||||
public function test_secret_post_is_not_indexed(): void
|
||||
{
|
||||
$post = (object) ['id' => 101, 'status' => PostStatus::Published, 'is_secret' => true, 'updated_at' => now()];
|
||||
|
||||
$this->listener->onPostCreate($post, 'notice');
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '101')->count(), '비밀글은 색인되면 안 됩니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 블라인드 처리(수정)되면 색인이 제거된다.
|
||||
*/
|
||||
public function test_blinded_post_update_removes_index(): void
|
||||
{
|
||||
$this->listener->onPostCreate(
|
||||
(object) ['id' => 102, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
|
||||
'notice'
|
||||
);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '102')->count());
|
||||
|
||||
$this->listener->onPostUpdate(
|
||||
(object) ['id' => 102, 'status' => PostStatus::Blinded, 'is_secret' => false, 'updated_at' => now()],
|
||||
'notice'
|
||||
);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '102')->count(), '블라인드 게시글은 색인에서 제거돼야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 삭제 시 색인이 제거된다.
|
||||
*/
|
||||
public function test_post_delete_removes_index(): void
|
||||
{
|
||||
$this->listener->onPostCreate(
|
||||
(object) ['id' => 103, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
|
||||
'notice'
|
||||
);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '103')->count());
|
||||
|
||||
$this->listener->onPostDelete((object) ['id' => 103], 'notice');
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '103')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성/비활성 게시판 수정이 색인을 토글한다.
|
||||
*/
|
||||
public function test_board_visibility_toggles_index(): void
|
||||
{
|
||||
$this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => true, 'updated_at' => now()]);
|
||||
$row = SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertStringEndsWith('/board/free', $row->loc);
|
||||
|
||||
$this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => false, 'updated_at' => now()]);
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 'SEO 제공 페이지(게시글 상세)' 토글이 OFF 면 공개 게시글이어도 색인이 제거된다.
|
||||
*
|
||||
* 리스너의 공개상태 판정은 게시 상태·비밀글 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치).
|
||||
*/
|
||||
public function test_post_detail_toggle_off_removes_index_even_when_public(): void
|
||||
{
|
||||
// 토글 ON 상태로 먼저 색인
|
||||
$this->listener->onPostCreate(
|
||||
(object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
|
||||
'notice'
|
||||
);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '110')->count());
|
||||
|
||||
// 토글 OFF → 공개 게시글이어도 색인 제거
|
||||
Config::set('g7_settings.modules.sirsoft-board.seo.seo_post_detail', false);
|
||||
$this->listener->onPostUpdate(
|
||||
(object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()],
|
||||
'notice'
|
||||
);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '110')->count(), 'seo_post_detail OFF 면 공개 게시글도 색인에서 제거돼야 합니다.');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener;
|
||||
@@ -38,6 +42,10 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
|
||||
$this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class);
|
||||
$this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock);
|
||||
|
||||
// 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
|
||||
$this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoBoardCacheListener;
|
||||
}
|
||||
|
||||
@@ -77,6 +85,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
|
||||
* 이 훅 미구독이 "게시판 정보 수정 후 SEO 메타 캐시 미무효화" 버그의 원인이었습니다.
|
||||
*
|
||||
* @scenario name_changed=no, description_changed=no
|
||||
*
|
||||
* @effects board_after_update_hook_subscribed
|
||||
*/
|
||||
public function test_subscribes_board_after_update_hook(): void
|
||||
@@ -98,6 +107,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
|
||||
* sitemap 캐시를 무효화해야 합니다. (게시글 상세는 board name 미노출이라 제외)
|
||||
*
|
||||
* @scenario name_changed=yes, description_changed=no
|
||||
*
|
||||
* @effects board_update_invalidates_board_list_url, board_update_invalidates_boards_index_url, board_update_invalidates_home_and_search_layouts, board_update_invalidates_sitemap_cache
|
||||
*/
|
||||
public function test_on_board_update_invalidates_board_seo_caches(): void
|
||||
@@ -390,9 +400,9 @@ class SeoBoardCacheListenerTest extends ModuleTestCase
|
||||
});
|
||||
|
||||
// sitemap 캐시(seo.sitemap) forget 검증 — CacheInterface
|
||||
$cacheInterfaceMock = Mockery::mock(\App\Contracts\Extension\CacheInterface::class);
|
||||
$cacheInterfaceMock = Mockery::mock(CacheInterface::class);
|
||||
$cacheInterfaceMock->shouldReceive('forget')->once()->with('seo.sitemap');
|
||||
$this->app->instance(\App\Contracts\Extension\CacheInterface::class, $cacheInterfaceMock);
|
||||
$this->app->instance(CacheInterface::class, $cacheInterfaceMock);
|
||||
|
||||
Log::shouldReceive('debug')->atLeast()->once();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,7 @@ class SeoCategoryCacheListener implements HookListenerInterface
|
||||
'priority' => 20,
|
||||
],
|
||||
'sirsoft-ecommerce.category.after_delete' => [
|
||||
'method' => 'onCategoryChange',
|
||||
'method' => 'onCategoryDelete',
|
||||
'priority' => 20,
|
||||
],
|
||||
];
|
||||
@@ -87,5 +89,75 @@ class SeoCategoryCacheListener implements HookListenerInterface
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->syncSitemapIndex($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제 시 캐시를 무효화하고 사이트맵 색인을 제거합니다.
|
||||
*
|
||||
* after_delete 훅은 모델이 아닌 카테고리 ID(int)를 전달합니다.
|
||||
*
|
||||
* @param mixed ...$args 훅 인자 (첫 번째: 카테고리 ID)
|
||||
*/
|
||||
public function onCategoryDelete(...$args): void
|
||||
{
|
||||
$this->onCategoryChange(...$args);
|
||||
|
||||
$categoryId = $args[0] ?? null;
|
||||
if ($categoryId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(SitemapIndexer::class)->deindexResource('category', is_object($categoryId) ? $categoryId->id : $categoryId);
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Category sitemap index removal failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리의 사이트맵 색인을 증분 갱신합니다.
|
||||
*
|
||||
* 활성(is_active) 이며 'SEO 제공 페이지(카테고리)' 토글이 켜져 있으면 색인(upsert),
|
||||
* 아니면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다.
|
||||
* 색인 규칙은 EcommerceSitemapContributor 의 카테고리 URL 규칙과 일치해야 합니다.
|
||||
*
|
||||
* @param mixed $category Category 모델 (create/update)
|
||||
*/
|
||||
private function syncSitemapIndex(mixed $category): void
|
||||
{
|
||||
if (! is_object($category) || ! isset($category->id) || ! isset($category->slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexer = app(SitemapIndexer::class);
|
||||
|
||||
$visible = (bool) ($category->is_active ?? false)
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/category/{$category->slug}",
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => 0.6,
|
||||
]]);
|
||||
} else {
|
||||
$indexer->deindexResource('category', $category->id);
|
||||
}
|
||||
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Category sitemap index sync failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'category_id' => $category->id ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Ecommerce\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
|
||||
/**
|
||||
* 상품 변경 시 SEO 캐시 무효화 리스너
|
||||
@@ -59,6 +62,7 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
{
|
||||
$this->invalidateRelatedCaches($args);
|
||||
$this->regenerateDetailCache($args);
|
||||
$this->syncSitemapIndex($args, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +74,7 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
{
|
||||
$this->invalidateRelatedCaches($args);
|
||||
$this->regenerateDetailCache($args);
|
||||
$this->syncSitemapIndex($args, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +87,7 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
public function onProductDelete(...$args): void
|
||||
{
|
||||
$this->invalidateRelatedCaches($args);
|
||||
$this->syncSitemapIndex($args, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +133,53 @@ class SeoProductCacheListener implements HookListenerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품의 사이트맵 색인을 증분 갱신합니다.
|
||||
*
|
||||
* 전시(display_status=VISIBLE)이며 'SEO 제공 페이지(상품 상세)' 토글이 켜져 있으면 색인(upsert),
|
||||
* 비공개/토글 OFF/삭제이면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다.
|
||||
* 색인 규칙은 EcommerceSitemapContributor 의 상품 URL 규칙과 일치해야 합니다.
|
||||
*
|
||||
* @param array $args 훅 인자 배열 (첫 번째: Product 모델)
|
||||
* @param bool $deleted 삭제 이벤트 여부
|
||||
*/
|
||||
private function syncSitemapIndex(array $args, bool $deleted): void
|
||||
{
|
||||
$product = $args[0] ?? null;
|
||||
|
||||
if (! $product || ! isset($product->id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexer = app(SitemapIndexer::class);
|
||||
|
||||
$visible = ! $deleted
|
||||
&& ($product->display_status ?? null) === ProductDisplayStatus::VISIBLE
|
||||
&& (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true);
|
||||
|
||||
if ($visible) {
|
||||
$routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop');
|
||||
$indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[
|
||||
'url' => "/{$routePath}/products/{$product->id}",
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => 0.8,
|
||||
]]);
|
||||
} else {
|
||||
$indexer->deindexResource('product', $product->id);
|
||||
}
|
||||
|
||||
// 파일 재작성은 잡에 위임 (ShouldBeUnique 로 디바운스). auto 모드 → 저장소 기반 증분.
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Product sitemap index sync failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'product_id' => $product->id ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다.
|
||||
*
|
||||
|
||||
@@ -81,6 +81,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
'url' => "/{$routePath}/products",
|
||||
'changefreq' => 'daily',
|
||||
'priority' => 0.7,
|
||||
'resource_type' => 'shop_index',
|
||||
'resource_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -92,6 +94,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
'lastmod' => $category->updated_at?->toW3cString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => 0.6,
|
||||
'resource_type' => 'category',
|
||||
'resource_id' => (string) $category->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -104,6 +108,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor
|
||||
'lastmod' => $product->updated_at?->toW3cString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => 0.8,
|
||||
'resource_type' => 'product',
|
||||
'resource_id' => (string) $product->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Seo;
|
||||
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Models\SitemapUrl;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Ecommerce 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
|
||||
*
|
||||
* 검증 목적 (DoD: 공개→append / 비공개→remove):
|
||||
* - 전시 상품 생성/수정 → 색인(append), 숨김/삭제 → 색인 제거
|
||||
* - 활성 카테고리 변경 → 색인, 비활성/삭제 → 색인 제거
|
||||
* - 리스너가 사이트맵 재생성 잡을 디스패치
|
||||
*/
|
||||
class EcommerceSitemapIndexTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전시 상품 생성 시 사이트맵에 색인(append)되고 잡이 디스패치된다.
|
||||
*/
|
||||
public function test_visible_product_create_appends_to_sitemap(): void
|
||||
{
|
||||
$product = (object) ['id' => 42, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()];
|
||||
|
||||
(new SeoProductCacheListener)->onProductCreate($product);
|
||||
|
||||
$row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '42')->first();
|
||||
$this->assertNotNull($row, '전시 상품은 사이트맵에 색인돼야 합니다.');
|
||||
$this->assertStringEndsWith('/products/42', $row->loc);
|
||||
$this->assertSame('sirsoft-ecommerce', $row->contributor);
|
||||
|
||||
Bus::assertDispatched(GenerateSitemapJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 숨김 상품으로 수정 시 사이트맵 색인이 제거(remove)된다.
|
||||
*/
|
||||
public function test_hidden_product_update_removes_from_sitemap(): void
|
||||
{
|
||||
// 먼저 전시로 색인
|
||||
(new SeoProductCacheListener)->onProductCreate(
|
||||
(object) ['id' => 7, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]
|
||||
);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '7')->count());
|
||||
|
||||
// 숨김으로 전환
|
||||
(new SeoProductCacheListener)->onProductUpdate(
|
||||
(object) ['id' => 7, 'display_status' => ProductDisplayStatus::HIDDEN, 'updated_at' => now()]
|
||||
);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '7')->count(), '숨김 상품은 색인에서 제거돼야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품 삭제 시 사이트맵 색인이 제거된다.
|
||||
*/
|
||||
public function test_product_delete_removes_from_sitemap(): void
|
||||
{
|
||||
(new SeoProductCacheListener)->onProductCreate(
|
||||
(object) ['id' => 9, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]
|
||||
);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '9')->count());
|
||||
|
||||
(new SeoProductCacheListener)->onProductDelete((object) ['id' => 9]);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 카테고리 변경 시 색인, 비활성 시 색인 제거.
|
||||
*/
|
||||
public function test_category_visibility_toggles_index(): void
|
||||
{
|
||||
$listener = new SeoCategoryCacheListener;
|
||||
|
||||
$listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => true, 'updated_at' => now()]);
|
||||
$row = SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertStringContainsString('/category/shoes', $row->loc);
|
||||
|
||||
$listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => false, 'updated_at' => now()]);
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 삭제(ID 전달) 시 색인이 제거된다.
|
||||
*/
|
||||
public function test_category_delete_removes_index(): void
|
||||
{
|
||||
$listener = new SeoCategoryCacheListener;
|
||||
$listener->onCategoryChange((object) ['id' => 4, 'slug' => 'bags', 'is_active' => true, 'updated_at' => now()]);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count());
|
||||
|
||||
// after_delete 훅은 모델이 아닌 ID(int)를 전달
|
||||
$listener->onCategoryDelete(4);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 'SEO 제공 페이지(상품 상세)' 토글이 OFF 면 전시 상품이어도 색인이 제거된다.
|
||||
*
|
||||
* 리스너의 공개상태 판정은 전시 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치).
|
||||
*/
|
||||
public function test_product_detail_toggle_off_removes_index_even_when_visible(): void
|
||||
{
|
||||
$listener = new SeoProductCacheListener;
|
||||
|
||||
// 토글 ON 상태로 먼저 색인
|
||||
$listener->onProductCreate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '50')->count());
|
||||
|
||||
// 토글 OFF → 전시 상태여도 색인 제거
|
||||
Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false);
|
||||
$listener->onProductUpdate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '50')->count(), 'seo_product_detail OFF 면 전시 상품도 색인에서 제거돼야 합니다.');
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -19,9 +23,20 @@ class SeoCategoryCacheListenerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
|
||||
$this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoCategoryCacheListener;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// getSubscribedHooks() 테스트
|
||||
// ========================================
|
||||
@@ -33,18 +48,22 @@ class SeoCategoryCacheListenerTest extends TestCase
|
||||
{
|
||||
$hooks = SeoCategoryCacheListener::getSubscribedHooks();
|
||||
|
||||
$expectedHooks = [
|
||||
// create/update 는 onCategoryChange, delete 는 onCategoryDelete (모델 대신 ID 를 받으므로 분리)
|
||||
$changeHooks = [
|
||||
'sirsoft-ecommerce.category.after_create',
|
||||
'sirsoft-ecommerce.category.after_update',
|
||||
'sirsoft-ecommerce.category.after_delete',
|
||||
];
|
||||
|
||||
foreach ($expectedHooks as $hookName) {
|
||||
foreach ($changeHooks as $hookName) {
|
||||
$this->assertArrayHasKey($hookName, $hooks);
|
||||
$this->assertEquals('onCategoryChange', $hooks[$hookName]['method']);
|
||||
$this->assertEquals(20, $hooks[$hookName]['priority']);
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.category.after_delete', $hooks);
|
||||
$this->assertEquals('onCategoryDelete', $hooks['sirsoft-ecommerce.category.after_delete']['method']);
|
||||
$this->assertEquals(20, $hooks['sirsoft-ecommerce.category.after_delete']['priority']);
|
||||
|
||||
$this->assertCount(3, $hooks);
|
||||
}
|
||||
|
||||
@@ -110,6 +129,9 @@ class SeoCategoryCacheListenerTest extends TestCase
|
||||
|
||||
// When
|
||||
$this->listener->onCategoryChange($category);
|
||||
|
||||
// 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +157,9 @@ class SeoCategoryCacheListenerTest extends TestCase
|
||||
|
||||
// When
|
||||
$this->listener->onCategoryChange($categoryId);
|
||||
|
||||
// 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +213,9 @@ class SeoCategoryCacheListenerTest extends TestCase
|
||||
|
||||
// When & Then: 예외가 전파되지 않음
|
||||
$this->listener->onCategoryChange($category);
|
||||
|
||||
// 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
+7
@@ -2,8 +2,11 @@
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners;
|
||||
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheRegenerator;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener;
|
||||
@@ -36,6 +39,10 @@ class SeoProductCacheListenerTest extends TestCase
|
||||
$this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class);
|
||||
$this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock);
|
||||
|
||||
// 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
|
||||
$this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoProductCacheListener;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
### Added
|
||||
|
||||
- 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
|
||||
- 페이지를 발행/미발행하거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 페이지가 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Page\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,7 @@ class SeoPageCacheListener implements HookListenerInterface
|
||||
'priority' => 20,
|
||||
],
|
||||
'sirsoft-page.page.after_delete' => [
|
||||
'method' => 'onPageChange',
|
||||
'method' => 'onPageDelete',
|
||||
'priority' => 20,
|
||||
],
|
||||
// 버전 복원도 콘텐츠(title/content/seo_meta) 변경이므로 수정과 동일하게 무효화
|
||||
@@ -94,5 +96,60 @@ class SeoPageCacheListener implements HookListenerInterface
|
||||
'page_id' => is_object($page) ? ($page->id ?? null) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->syncSitemapIndex($page, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 삭제 시 SEO 캐시를 무효화하고 사이트맵 색인을 제거합니다.
|
||||
*
|
||||
* @param mixed ...$args 훅 인자 (첫 번째: Page 모델)
|
||||
*/
|
||||
public function onPageDelete(...$args): void
|
||||
{
|
||||
$this->onPageChange(...$args);
|
||||
|
||||
$this->syncSitemapIndex($args[0] ?? null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지의 사이트맵 색인을 증분 갱신합니다.
|
||||
*
|
||||
* 발행(published) 상태이면 색인(upsert), 미발행/삭제이면 색인 해제(remove)한 뒤
|
||||
* 사이트맵 재생성 잡을 디바운스 디스패치합니다.
|
||||
* 색인 규칙은 PageSitemapContributor 의 페이지 URL 규칙과 일치해야 합니다.
|
||||
*
|
||||
* @param mixed $page Page 모델
|
||||
* @param bool $deleted 삭제 이벤트 여부
|
||||
*/
|
||||
private function syncSitemapIndex(mixed $page, bool $deleted): void
|
||||
{
|
||||
if (! is_object($page) || ! isset($page->id) || ! isset($page->slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexer = app(SitemapIndexer::class);
|
||||
|
||||
$visible = ! $deleted && (bool) ($page->published ?? false);
|
||||
|
||||
if ($visible) {
|
||||
$indexer->indexResource('page', $page->id, 'sirsoft-page', [[
|
||||
'url' => "/page/{$page->slug}",
|
||||
'lastmod' => $page->updated_at?->toW3cString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => 0.5,
|
||||
]]);
|
||||
} else {
|
||||
$indexer->deindexResource('page', $page->id);
|
||||
}
|
||||
|
||||
GenerateSitemapJob::dispatch();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[SEO] Page sitemap index sync failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'page_id' => is_object($page) ? ($page->id ?? null) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,8 @@ class PageSitemapContributor extends AbstractSitemapContributor
|
||||
'lastmod' => $page->updated_at?->toW3cString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => 0.5,
|
||||
'resource_type' => 'page',
|
||||
'resource_id' => (string) $page->id,
|
||||
];
|
||||
$emitted++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Feature\Seo;
|
||||
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Models\SitemapUrl;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* Page 리스너 사이트맵 증분 색인 테스트 (S4 ⑲)
|
||||
*
|
||||
* 검증 목적 (DoD: 공개→append / 비공개→remove):
|
||||
* - 발행(published=true) 페이지 → 색인, 미발행 → 색인 제거
|
||||
* - 페이지 삭제 → 색인 제거
|
||||
*/
|
||||
class PageSitemapIndexTest extends ModuleTestCase
|
||||
{
|
||||
private SeoPageCacheListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 캐시 무효화는 이 테스트 범위 밖 — spy 로 대체
|
||||
$this->app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoPageCacheListener;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행된 페이지 생성/수정 시 색인(append)된다.
|
||||
*/
|
||||
public function test_published_page_is_indexed(): void
|
||||
{
|
||||
$page = (object) ['id' => 200, 'slug' => 'about', 'published' => true, 'updated_at' => now()];
|
||||
|
||||
$this->listener->onPageChange($page);
|
||||
|
||||
$row = SitemapUrl::where('resource_type', 'page')->where('resource_id', '200')->first();
|
||||
$this->assertNotNull($row, '발행 페이지는 색인돼야 합니다.');
|
||||
$this->assertStringEndsWith('/page/about', $row->loc);
|
||||
Bus::assertDispatched(GenerateSitemapJob::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미발행 페이지는 색인되지 않고, 발행→미발행 전환 시 색인이 제거된다.
|
||||
*/
|
||||
public function test_unpublished_page_removes_index(): void
|
||||
{
|
||||
$this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => true, 'updated_at' => now()]);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '201')->count());
|
||||
|
||||
$this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => false, 'updated_at' => now()]);
|
||||
$this->assertSame(0, SitemapUrl::where('resource_id', '201')->count(), '미발행 전환 시 색인이 제거돼야 합니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 삭제 시 색인이 제거된다.
|
||||
*/
|
||||
public function test_page_delete_removes_index(): void
|
||||
{
|
||||
$this->listener->onPageChange((object) ['id' => 202, 'slug' => 'gone', 'published' => true, 'updated_at' => now()]);
|
||||
$this->assertSame(1, SitemapUrl::where('resource_id', '202')->count());
|
||||
|
||||
$this->listener->onPageDelete((object) ['id' => 202, 'slug' => 'gone', 'published' => true]);
|
||||
|
||||
$this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '202')->count());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
namespace Modules\Sirsoft\Page\Tests\Unit\Listeners;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Jobs\GenerateSitemapJob;
|
||||
use App\Seo\Contracts\SeoCacheManagerInterface;
|
||||
use App\Seo\SeoCacheManager;
|
||||
use App\Seo\SitemapIndexer;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
@@ -25,9 +29,20 @@ class SeoPageCacheListenerTest extends ModuleTestCase
|
||||
parent::setUp();
|
||||
|
||||
$this->cache = $this->app->make(SeoCacheManagerInterface::class);
|
||||
|
||||
// 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단
|
||||
$this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class));
|
||||
Bus::fake([GenerateSitemapJob::class]);
|
||||
|
||||
$this->listener = new SeoPageCacheListener;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─── 훅 구독 등록 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -45,7 +60,8 @@ class SeoPageCacheListenerTest extends ModuleTestCase
|
||||
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']);
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']);
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_delete']['method']);
|
||||
// delete 는 모델 상태와 무관하게 색인을 제거해야 하므로 전용 메서드로 분리
|
||||
$this->assertEquals('onPageDelete', $hooks['sirsoft-page.page.after_delete']['method']);
|
||||
$this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']);
|
||||
|
||||
$this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']);
|
||||
|
||||
Reference in new issue
Block a user