Files
Gnuboard7/tests/Feature/Seo/SaveSettingsRequestSeoTest.php
T
HeuJung 1a6fca0d6b feat(seo): 사이트맵 기여자 지연 스트리밍 + hreflang 계산 선형화
AbstractSitemapContributor 브리지 base 로 인터페이스를 바꾸지 않고
board/ecommerce/page 기여자를 getUrlsLazy 지연 스트리밍으로 전환해,
게시글이 수백만 건인 사이트에서도 URL 배열을 통째로 적재하지 않는다.
SitemapGenerator 는 capability 를 감지해 지연 경로를 쓰고, base 를
상속하지 않은 제3자 구현체는 기존 getUrls 경로로 그대로 동작한다.

hreflang alternate 집합을 base URL 당 한 번만 계산해 로케일별 <url>
에 재사용하도록 바꿔 계산 비용을 로케일 수의 제곱에서 선형으로 줄였다
(정상 구성 출력은 불변). 다국어 대체 링크 사용 여부 토글과 로케일 수
상한을 추가하고 설정/검증/관리자 UI/다국어(ko·en·ja) 표면을 동반했다.
2026-07-18 15:05:56 +09:00

664 lines
21 KiB
PHP

<?php
namespace Tests\Feature\Seo;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
/**
* SaveSettingsRequest SEO 필드 검증 테스트
*
* 코어 SEO 설정 필드(bot_user_agents, cache_ttl, sitemap_schedule 등)의
* 유효성 검증 규칙이 올바르게 동작하는지 API 요청을 통해 검증합니다.
*/
class SaveSettingsRequestSeoTest 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;
}
/**
* 저장된 설정 카테고리를 디스크에서 다시 읽습니다.
*
* g7_core_settings() 는 부팅 시점에 로드된 Config 를 보므로, 같은 프로세스에서
* 저장 직후 값을 확인할 때는 저장소를 직접 읽어야 합니다.
*
* @param string $category 설정 카테고리
* @return array 저장된 설정 배열
*/
private function storedSettings(string $category): array
{
return app(ConfigRepositoryInterface::class)->getCategory($category);
}
/**
* 관리자 역할 및 권한을 가진 사용자를 생성합니다.
*
* @param array $permissions 부여할 권한 식별자 목록
*/
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,
]);
$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);
$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 탭 설정 저장 요청을 전송합니다.
*
* @param array $seoData SEO 필드 데이터
*/
private function postSeoSettings(array $seoData): TestResponse
{
return $this->actingAs($this->admin)
->postJson('/api/admin/settings', [
'_tab' => 'seo',
'seo' => $seoData,
]);
}
// ========================================
// bot_user_agents 검증 테스트
// ========================================
/**
* bot_user_agents 배열 전송 시 유효성 검증 통과
*/
public function test_bot_user_agents_array_passes_validation(): void
{
$response = $this->postSeoSettings([
'bot_user_agents' => ['Googlebot', 'Bingbot', 'Yandex'],
]);
$response->assertStatus(200);
}
/**
* bot_user_agents 빈 배열 전송 시 유효성 검증 통과
*/
public function test_bot_user_agents_empty_array_passes_validation(): void
{
$response = $this->postSeoSettings([
'bot_user_agents' => [],
]);
$response->assertStatus(200);
}
/**
* bot_user_agents 비배열 전송 시 422 응답
*/
public function test_bot_user_agents_non_array_fails_validation(): void
{
$response = $this->postSeoSettings([
'bot_user_agents' => 'Googlebot',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.bot_user_agents']);
}
/**
* bot_user_agents 요소가 100자 초과 시 422 응답
*/
public function test_bot_user_agents_element_exceeding_100_chars_fails(): void
{
$response = $this->postSeoSettings([
'bot_user_agents' => [str_repeat('a', 101)],
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.bot_user_agents.0']);
}
// ========================================
// cache 관련 검증 테스트
// ========================================
/**
* cache_enabled boolean 전송 시 유효성 검증 통과
*/
public function test_cache_enabled_boolean_passes_validation(): void
{
$response = $this->postSeoSettings([
'cache_enabled' => true,
]);
$response->assertStatus(200);
}
/**
* cache_ttl 범위 내 값 전송 시 유효성 검증 통과
*/
public function test_cache_ttl_in_range_passes_validation(): void
{
$response = $this->postSeoSettings([
'cache_ttl' => 3600,
]);
$response->assertStatus(200);
}
/**
* cache_ttl 최소값 미만 시 422 응답
*/
public function test_cache_ttl_below_minimum_fails_validation(): void
{
$response = $this->postSeoSettings([
'cache_ttl' => 59,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.cache_ttl']);
}
/**
* cache_ttl 최대값 초과 시 422 응답
*/
public function test_cache_ttl_above_maximum_fails_validation(): void
{
$response = $this->postSeoSettings([
'cache_ttl' => 86401,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.cache_ttl']);
}
// ========================================
// sitemap 관련 검증 테스트
// ========================================
/**
* sitemap_schedule 유효 값(daily) 전송 시 검증 통과
*/
public function test_sitemap_schedule_valid_value_passes_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_schedule' => 'daily',
]);
$response->assertStatus(200);
}
/**
* sitemap_schedule 유효하지 않은 값(monthly) 전송 시 422 응답
*/
public function test_sitemap_schedule_invalid_value_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_schedule' => 'monthly',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_schedule']);
}
/**
* sitemap_schedule_time 유효 형식(02:00) 전송 시 검증 통과
*/
public function test_sitemap_schedule_time_valid_format_passes(): void
{
$response = $this->postSeoSettings([
'sitemap_schedule_time' => '02:00',
]);
$response->assertStatus(200);
}
/**
* sitemap_schedule_time 유효하지 않은 형식(2:00) 전송 시 422 응답
*/
public function test_sitemap_schedule_time_invalid_format_fails(): void
{
$response = $this->postSeoSettings([
'sitemap_schedule_time' => '2:00',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_schedule_time']);
}
// ========================================
// sitemap_cache_ttl 검증 테스트
// ========================================
/**
* sitemap_cache_ttl 범위 내 값 전송 시 유효성 검증 통과
*/
public function test_sitemap_cache_ttl_in_range_passes_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_cache_ttl' => 86400,
]);
$response->assertStatus(200);
}
/**
* sitemap_cache_ttl 최소값 미만 시 422 응답
*/
public function test_sitemap_cache_ttl_below_minimum_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_cache_ttl' => 3599,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_cache_ttl']);
}
/**
* sitemap_cache_ttl 최대값 초과 시 422 응답
*/
public function test_sitemap_cache_ttl_above_maximum_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_cache_ttl' => 604801,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_cache_ttl']);
}
// ========================================
// bot_detection_enabled 검증 테스트
// ========================================
/**
* bot_detection_enabled boolean 전송 시 유효성 검증 통과
*/
public function test_bot_detection_enabled_boolean_passes_validation(): void
{
$response = $this->postSeoSettings([
'bot_detection_enabled' => false,
]);
$response->assertStatus(200);
}
// ========================================
// sitemap_enabled 검증 테스트
// ========================================
/**
* sitemap_enabled boolean 전송 시 유효성 검증 통과
*/
public function test_sitemap_enabled_boolean_passes_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_enabled' => true,
]);
$response->assertStatus(200);
}
// ========================================
// generator 메타 태그 검증 테스트
// ========================================
/**
* generator_enabled boolean 전송 시 유효성 검증 통과
*/
public function test_generator_enabled_boolean_passes_validation(): void
{
$response = $this->postSeoSettings([
'generator_enabled' => false,
]);
$response->assertStatus(200);
}
/**
* generator_content 200자 이내 전송 시 유효성 검증 통과
*/
public function test_generator_content_within_limit_passes_validation(): void
{
$response = $this->postSeoSettings([
'generator_enabled' => true,
'generator_content' => 'GnuBoard7',
]);
$response->assertStatus(200);
}
/**
* generator_content 200자 초과 전송 시 422 응답
*/
public function test_generator_content_exceeding_limit_fails_validation(): void
{
$response = $this->postSeoSettings([
'generator_content' => str_repeat('a', 201),
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.generator_content']);
}
// ========================================
// sitemap 분할/압축/서빙 설정 검증 테스트
// ========================================
/**
* sitemap 분할/압축/서빙 설정이 저장되어 설정값에 반영되는지 확인
*/
public function test_sitemap_generation_settings_are_saved(): void
{
$response = $this->postSeoSettings([
'sitemap_urls_per_file' => 20000,
'sitemap_gzip' => true,
'sitemap_serve_stale_on_miss' => false,
'sitemap_max_urls_per_contributor' => 100000,
]);
$response->assertStatus(200);
$seo = $this->storedSettings('seo');
$this->assertSame(20000, $seo['sitemap_urls_per_file']);
$this->assertTrue((bool) $seo['sitemap_gzip']);
$this->assertFalse((bool) $seo['sitemap_serve_stale_on_miss']);
$this->assertSame(100000, $seo['sitemap_max_urls_per_contributor']);
}
/**
* 파일당 URL 수 상한(50000)을 넘기면 422 응답
*
* 50000 은 sitemaps.org 프로토콜 상한이므로 저장 자체를 막습니다.
*/
public function test_sitemap_urls_per_file_exceeding_protocol_limit_fails(): void
{
$response = $this->postSeoSettings([
'sitemap_urls_per_file' => 50001,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_urls_per_file']);
}
/**
* 파일당 URL 수 하한(1000) 미만이면 422 응답
*/
public function test_sitemap_urls_per_file_below_minimum_fails(): void
{
$response = $this->postSeoSettings([
'sitemap_urls_per_file' => 999,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_urls_per_file']);
}
/**
* 파일당 URL 수 경계값(1000/50000)은 통과하는지 확인
*/
public function test_sitemap_urls_per_file_boundary_values_pass(): void
{
$this->postSeoSettings(['sitemap_urls_per_file' => 1000])->assertStatus(200);
$this->postSeoSettings(['sitemap_urls_per_file' => 50000])->assertStatus(200);
}
/**
* 수집기당 최대 URL 수는 0(무제한)을 허용하고 음수는 거부하는지 확인
*/
public function test_sitemap_max_urls_per_contributor_allows_zero_and_rejects_negative(): void
{
$this->postSeoSettings(['sitemap_max_urls_per_contributor' => 0])->assertStatus(200);
$this->postSeoSettings(['sitemap_max_urls_per_contributor' => -1])
->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_max_urls_per_contributor']);
}
/**
* gzip 이 boolean 이 아니면 422 응답
*/
public function test_sitemap_gzip_non_boolean_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_gzip' => 'yes-please',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_gzip']);
}
/**
* hreflang 사용 여부가 boolean 으로 저장되는지 확인
*/
public function test_sitemap_hreflang_enabled_is_saved(): void
{
$this->postSeoSettings(['sitemap_hreflang_enabled' => false])->assertStatus(200);
$seo = $this->storedSettings('seo');
$this->assertFalse((bool) $seo['sitemap_hreflang_enabled']);
}
/**
* hreflang 사용 여부가 boolean 이 아니면 422 응답
*/
public function test_sitemap_hreflang_enabled_non_boolean_fails_validation(): void
{
$response = $this->postSeoSettings([
'sitemap_hreflang_enabled' => 'maybe',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['seo.sitemap_hreflang_enabled']);
}
// ========================================
// 고급 탭 Sitemap 캐시 기준값 (D19 의 메인 값)
// ========================================
/**
* 고급 탭 설정 저장 요청을 전송합니다.
*
* @param array $advancedData 고급 필드 데이터
*/
private function postAdvancedSettings(array $advancedData): TestResponse
{
return $this->actingAs($this->admin)
->postJson('/api/admin/settings', [
'_tab' => 'advanced',
'advanced' => $advancedData,
]);
}
/**
* 고급 탭의 Sitemap 캐시 기준값이 저장되어 cache 카테고리에 반영되는지 확인
*/
public function test_advanced_sitemap_cache_ttl_is_saved_to_cache_category(): void
{
$response = $this->postAdvancedSettings([
'cache_enabled' => true,
'layout_cache_enabled' => true,
'layout_cache_ttl' => 3600,
'stats_cache_enabled' => true,
'stats_cache_ttl' => 3600,
'seo_cache_enabled' => true,
'seo_cache_ttl' => 3600,
'seo_sitemap_cache_ttl' => 43200,
'debug_mode' => false,
'sql_query_log' => false,
]);
$response->assertStatus(200);
$this->assertSame(43200, $this->storedSettings('cache')['seo_sitemap_ttl']);
}
/**
* 고급 탭 Sitemap 캐시 기준값이 기본값(86400)을 허용하는지 확인
*
* 형제 캐시 TTL 규칙(최대 14400)을 그대로 재사용하면 화면 기본값조차 저장할 수 없게 됩니다.
*/
public function test_advanced_sitemap_cache_ttl_accepts_default_value(): void
{
$this->postAdvancedSettings([
'cache_enabled' => true,
'layout_cache_enabled' => true,
'layout_cache_ttl' => 3600,
'stats_cache_enabled' => true,
'stats_cache_ttl' => 3600,
'seo_cache_enabled' => true,
'seo_cache_ttl' => 3600,
'seo_sitemap_cache_ttl' => 86400,
'debug_mode' => false,
'sql_query_log' => false,
])->assertStatus(200);
$this->assertSame(86400, $this->storedSettings('cache')['seo_sitemap_ttl']);
}
/**
* 고급 탭 Sitemap 캐시 기준값의 허용 범위를 벗어나면 422 응답
*/
public function test_advanced_sitemap_cache_ttl_out_of_range_fails(): void
{
$this->postAdvancedSettings(['seo_sitemap_cache_ttl' => 604801])
->assertStatus(422)
->assertJsonValidationErrors(['advanced.seo_sitemap_cache_ttl']);
$this->postAdvancedSettings(['seo_sitemap_cache_ttl' => 3599])
->assertStatus(422)
->assertJsonValidationErrors(['advanced.seo_sitemap_cache_ttl']);
}
/**
* 신규 키를 보내지 않는 기존 클라이언트의 고급 탭 저장이 계속 동작하는지 확인
*
* 형제 키처럼 required 로 두면 이전 화면/외부 클라이언트의 저장이 422 로 깨집니다.
*/
public function test_advanced_tab_saves_without_new_sitemap_cache_key(): void
{
$this->postAdvancedSettings([
'cache_enabled' => true,
'layout_cache_enabled' => true,
'layout_cache_ttl' => 3600,
'stats_cache_enabled' => true,
'stats_cache_ttl' => 3600,
'seo_cache_enabled' => true,
'seo_cache_ttl' => 3600,
'debug_mode' => false,
'sql_query_log' => false,
])->assertStatus(200);
}
/**
* 두 탭의 SEO 캐시 TTL 안내 문구가 각자의 실제 한도를 알려주는지 확인
*
* 두 탭이 같은 다국어 키를 공유하면 PHP 배열의 나중 정의가 이겨서
* 한쪽 탭이 반드시 틀린 한도를 안내하게 됩니다(ko/en 이 서로 반대로 틀어졌던 사례).
*/
public function test_seo_cache_ttl_messages_state_each_tabs_own_limit(): void
{
$seoTabErrors = $this->postSeoSettings(['cache_ttl' => 86401])
->assertStatus(422)
->json('errors');
$advancedTabErrors = $this->postAdvancedSettings(['seo_cache_ttl' => 14401])
->assertStatus(422)
->json('errors');
$this->assertStringContainsString('86400', $seoTabErrors['seo.cache_ttl'][0]);
$this->assertStringContainsString('14400', $advancedTabErrors['advanced.seo_cache_ttl'][0]);
}
// ========================================
// 캐시 오버라이드 칸 비우기 (D19)
// ========================================
/**
* 캐시 오버라이드 칸을 비워서 저장하면 null(미설정)로 정규화되는지 확인
*
* 폼에서 칸을 비우면 빈 문자열로 전송될 수 있는데, 이것이 null 로 정규화되지 않으면
* "미설정" 으로 판정되지 않아 고급 설정 값을 따르지 못하고 오버라이드가 계속 발동합니다.
*/
public function test_emptied_cache_override_fields_are_normalized_to_null(): void
{
$this->postSeoSettings([
'cache_ttl' => 3600,
'sitemap_cache_ttl' => 7200,
])->assertStatus(200);
$seo = $this->storedSettings('seo');
$this->assertSame(3600, $seo['cache_ttl']);
$this->assertSame(7200, $seo['sitemap_cache_ttl']);
$this->postSeoSettings([
'cache_ttl' => '',
'sitemap_cache_ttl' => '',
])->assertStatus(200);
$seo = $this->storedSettings('seo');
$this->assertNull($seo['cache_ttl']);
$this->assertNull($seo['sitemap_cache_ttl']);
}
}