봇 요청 스레드의 동기 생성을 제거하고 디스크 스트리밍 서빙으로 전환했다. 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale) 또는 503+Retry-After 로 응답해, 대용량에서 봇 요청이 OOM/타임아웃을 유발하던 경로를 끊었다. - 잡: ShouldBeUnique(고정 uniqueId)+uniqueFor+retryUntil, timeout 300→1800, backoff - 컨트롤러: 인덱스 stale/503 분기 + 자식 라우트(.xml/.xml.gz 동일 액션) - 스케줄 withoutOverlapping, 관리자 재생성 큐 이관 - 설정 4키(분할 기준·압축·stale 서빙·수집기 상한) + 고급탭 Sitemap 캐시 짝 - SitemapManager 응답 메시지 다국어화 설정 저장 테스트가 실제 설정 파일(사이트명/언어 포함)을 덮어쓰던 사전결함을 TestCase 전역 격리로 차단하고, 두 탭이 같은 다국어 키를 공유해 한쪽에 틀린 한도를 안내하던 사전결함을 키 분리로 해소했다.
469 lines
15 KiB
PHP
469 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Jobs\GenerateSitemapJob;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\SeoCacheStat;
|
|
use App\Models\User;
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use App\Seo\SitemapManager;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* SeoCacheController 테스트
|
|
*
|
|
* SEO 캐시 관리 API 엔드포인트를 테스트합니다.
|
|
* - 통계 조회 (stats)
|
|
* - 캐시 삭제 (clearCache)
|
|
* - 캐시 워밍업 (warmup)
|
|
* - 캐시된 URL 목록 (cachedUrls)
|
|
*/
|
|
class SeoCacheControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* 관리자 역할 생성 및 할당
|
|
*
|
|
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
|
|
* @return User 생성된 관리자 사용자
|
|
*/
|
|
private function createAdminUser(array $permissions = ['core.settings.read', 'core.settings.update']): User
|
|
{
|
|
$user = User::factory()->create([
|
|
'password' => Hash::make('password123'),
|
|
]);
|
|
|
|
// 권한 생성
|
|
$permissionIds = [];
|
|
foreach ($permissions as $permIdentifier) {
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
[
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
// 고유한 식별자로 역할 생성 (테스트별 격리를 위해)
|
|
$roleIdentifier = 'admin_test_'.uniqid();
|
|
$adminRole = Role::create([
|
|
'identifier' => $roleIdentifier,
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// admin 역할도 추가 (admin 미들웨어 통과용)
|
|
$adminBaseRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
// 테스트용 역할에 권한 할당
|
|
$adminRole->permissions()->sync($permissionIds);
|
|
|
|
// 사용자에게 admin 역할과 테스트용 역할 모두 할당
|
|
$user->roles()->attach($adminBaseRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
// ========================================================================
|
|
// 인증/권한 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 SEO 캐시 통계 조회 시 401 반환
|
|
*/
|
|
public function test_stats_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/seo/stats');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 권한 없이 SEO 캐시 통계 조회 시 403 반환
|
|
*/
|
|
public function test_stats_returns_403_without_permission(): void
|
|
{
|
|
// 권한 없는 관리자 생성
|
|
$user = User::factory()->create();
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withToken($token)->getJson('/api/admin/seo/stats');
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* read 권한만 있는 관리자가 POST 엔드포인트 호출 시 403 반환
|
|
*/
|
|
public function test_clear_cache_returns_403_without_update_permission(): void
|
|
{
|
|
// read 권한만 있는 관리자 생성
|
|
$user = $this->createAdminUser(['core.settings.read']);
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withToken($token)->postJson('/api/admin/seo/clear-cache');
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 통계 조회 테스트 (stats)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* SEO 캐시 통계 조회 성공
|
|
*/
|
|
public function test_stats_returns_200_with_stats_data(): void
|
|
{
|
|
// 테스트용 통계 데이터 생성
|
|
SeoCacheStat::create([
|
|
'url' => '/',
|
|
'locale' => 'ko',
|
|
'layout_name' => 'home',
|
|
'module_identifier' => null,
|
|
'type' => 'hit',
|
|
]);
|
|
|
|
SeoCacheStat::create([
|
|
'url' => '/shop/products',
|
|
'locale' => 'ko',
|
|
'layout_name' => 'products',
|
|
'module_identifier' => 'sirsoft-ecommerce',
|
|
'type' => 'miss',
|
|
'response_time_ms' => 150,
|
|
]);
|
|
|
|
$response = $this->withToken($this->token)->getJson('/api/admin/seo/stats');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson(['success' => true])
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'data' => [
|
|
'overall' => [
|
|
'total_entries',
|
|
'hits',
|
|
'misses',
|
|
'hit_rate',
|
|
'avg_response_time_ms',
|
|
],
|
|
'by_layout',
|
|
'by_module',
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 통계 데이터가 없을 때도 정상 응답
|
|
*/
|
|
public function test_stats_returns_200_with_empty_data(): void
|
|
{
|
|
$response = $this->withToken($this->token)->getJson('/api/admin/seo/stats');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'overall' => [
|
|
'total_entries' => 0,
|
|
'hits' => 0,
|
|
'misses' => 0,
|
|
'hit_rate' => 0.0,
|
|
],
|
|
'by_layout' => [],
|
|
'by_module' => [],
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 캐시 삭제 테스트 (clearCache)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 파라미터 없이 전체 캐시 삭제 성공
|
|
*/
|
|
public function test_clear_cache_clears_all_without_params(): void
|
|
{
|
|
$mock = Mockery::mock(SeoCacheManagerInterface::class);
|
|
$mock->shouldReceive('clearAll')->once();
|
|
$this->app->instance(SeoCacheManagerInterface::class, $mock);
|
|
|
|
$response = $this->withToken($this->token)->postJson('/api/admin/seo/clear-cache');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'cleared' => 'all',
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* layout 파라미터 지정 시 해당 레이아웃 캐시만 삭제 성공
|
|
*/
|
|
public function test_clear_cache_clears_specific_layout(): void
|
|
{
|
|
$mock = Mockery::mock(SeoCacheManagerInterface::class);
|
|
$mock->shouldReceive('invalidateByLayout')
|
|
->with('home')
|
|
->once()
|
|
->andReturn(5);
|
|
$this->app->instance(SeoCacheManagerInterface::class, $mock);
|
|
|
|
$response = $this->withToken($this->token)->postJson('/api/admin/seo/clear-cache', [
|
|
'layout' => 'home',
|
|
]);
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'cleared' => 5,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 캐시 워밍업 테스트 (warmup)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 캐시 워밍업 요청 시 dispatched 상태 반환
|
|
*/
|
|
public function test_warmup_returns_dispatched_status(): void
|
|
{
|
|
$response = $this->withToken($this->token)->postJson('/api/admin/seo/warmup');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'status' => 'dispatched',
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 인증 없이 워밍업 요청 시 401 반환
|
|
*/
|
|
public function test_warmup_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/admin/seo/warmup');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 캐시된 URL 목록 테스트 (cachedUrls)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 캐시된 URL 목록 조회 성공
|
|
*/
|
|
public function test_cached_urls_returns_url_list(): void
|
|
{
|
|
$expectedUrls = ['/', '/shop/products', '/about'];
|
|
|
|
$mock = Mockery::mock(SeoCacheManagerInterface::class);
|
|
$mock->shouldReceive('getCachedUrls')
|
|
->once()
|
|
->andReturn($expectedUrls);
|
|
$this->app->instance(SeoCacheManagerInterface::class, $mock);
|
|
|
|
$response = $this->withToken($this->token)->getJson('/api/admin/seo/cached-urls');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'urls' => $expectedUrls,
|
|
'count' => 3,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 캐시된 URL이 없을 때 빈 목록 반환
|
|
*/
|
|
public function test_cached_urls_returns_empty_list(): void
|
|
{
|
|
$mock = Mockery::mock(SeoCacheManagerInterface::class);
|
|
$mock->shouldReceive('getCachedUrls')
|
|
->once()
|
|
->andReturn([]);
|
|
$this->app->instance(SeoCacheManagerInterface::class, $mock);
|
|
|
|
$response = $this->withToken($this->token)->getJson('/api/admin/seo/cached-urls');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'urls' => [],
|
|
'count' => 0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 인증 없이 캐시된 URL 목록 조회 시 401 반환
|
|
*/
|
|
public function test_cached_urls_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/seo/cached-urls');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Sitemap 수동 재생성 (regenerateSitemap)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 sitemap 재생성 호출 시 401 반환
|
|
*/
|
|
public function test_regenerate_sitemap_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/admin/seo/sitemap/regenerate');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 재생성 요청 시 200 + 예약 상태와 직전 생성 시각을 반환
|
|
*/
|
|
public function test_regenerate_sitemap_returns_200_with_dispatched_status(): void
|
|
{
|
|
Queue::fake();
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', true);
|
|
Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-04-29T10:00:00+00:00');
|
|
|
|
$response = $this->withHeader('Authorization', 'Bearer '.$this->token)
|
|
->postJson('/api/admin/seo/sitemap/regenerate');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertTrue($response->json('success'));
|
|
$this->assertSame('dispatched', $response->json('data.status'));
|
|
$this->assertSame('2026-04-29T10:00:00+00:00', $response->json('data.last_updated_at'));
|
|
}
|
|
|
|
/**
|
|
* 재생성 요청이 큐 잡을 1건만 디스패치하는지 확인
|
|
*/
|
|
public function test_regenerate_sitemap_dispatches_job(): void
|
|
{
|
|
Queue::fake();
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', true);
|
|
|
|
$this->withHeader('Authorization', 'Bearer '.$this->token)
|
|
->postJson('/api/admin/seo/sitemap/regenerate')
|
|
->assertStatus(200);
|
|
|
|
Queue::assertPushed(GenerateSitemapJob::class, 1);
|
|
}
|
|
|
|
/**
|
|
* 재생성 요청이 요청 스레드에서 동기 생성하지 않는지 확인
|
|
*
|
|
* 이 회귀가 무너지면 1.4M 규모에서 관리자 워커가 OOM 으로 죽습니다.
|
|
*/
|
|
public function test_regenerate_sitemap_does_not_generate_inline(): void
|
|
{
|
|
Queue::fake();
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', true);
|
|
|
|
$mock = Mockery::mock(SitemapManager::class);
|
|
$mock->shouldNotReceive('regenerate');
|
|
$mock->shouldReceive('getStatus')->andReturn(['last_updated_at' => null]);
|
|
$this->app->instance(SitemapManager::class, $mock);
|
|
|
|
$this->withHeader('Authorization', 'Bearer '.$this->token)
|
|
->postJson('/api/admin/seo/sitemap/regenerate')
|
|
->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* Sitemap 비활성 상태 시 400 반환 + 잡 미디스패치
|
|
*/
|
|
public function test_regenerate_sitemap_returns_400_when_disabled(): void
|
|
{
|
|
Queue::fake();
|
|
Config::set('g7_settings.core.seo.sitemap_enabled', false);
|
|
|
|
$response = $this->withHeader('Authorization', 'Bearer '.$this->token)
|
|
->postJson('/api/admin/seo/sitemap/regenerate');
|
|
|
|
$response->assertStatus(400);
|
|
$this->assertFalse($response->json('success'));
|
|
Queue::assertNotPushed(GenerateSitemapJob::class);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
}
|