Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Unit/BoardSettingsServiceTest.php
T
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

432 lines
15 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Unit;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Board\Services\BoardPermissionService;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* BoardSettingsService 단위 테스트
*
* ModuleSettingsInterface 구현을 검증합니다.
*/
class BoardSettingsServiceTest extends ModuleTestCase
{
private BoardSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$permissionService = $this->createMock(BoardPermissionService::class);
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
// report_policy 저장 시 호출되는 알림 정의 동기화 경로가 빈 컬렉션을 안전하게 다루도록 기본 stub
$notificationDefinitionRepository->method('getByExtension')
->willReturn(new Collection);
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
// 테스트 격리 경로 — 운영 설정(storage/app/modules/...)을 지우거나 덮어쓰지 않는다
$this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
// 테스트 전 저장소 정리
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
}
protected function tearDown(): void
{
// 테스트 후 저장소 정리
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
parent::tearDown();
}
// ========================================
// getSettingsDefaultsPath 테스트
// ========================================
/**
* defaults.json 경로가 정상적으로 반환되는지 확인
*/
public function test_get_settings_defaults_path_returns_valid_path(): void
{
$path = $this->service->getSettingsDefaultsPath();
$this->assertNotNull($path);
$this->assertFileExists($path);
$this->assertStringEndsWith('defaults.json', $path);
}
// ========================================
// getAllSettings 테스트
// ========================================
/**
* 저장된 설정이 없으면 defaults.json의 기본값을 반환하는지 확인
*/
public function test_get_all_settings_returns_defaults_when_no_saved_settings(): void
{
$settings = $this->service->getAllSettings();
$this->assertIsArray($settings);
$this->assertArrayHasKey('basic_defaults', $settings);
$this->assertArrayHasKey('report_policy', $settings);
$this->assertArrayHasKey('spam_security', $settings);
}
/**
* basic_defaults 카테고리의 기본값이 올바른지 확인
*/
public function test_get_all_settings_has_correct_basic_defaults(): void
{
$settings = $this->service->getAllSettings();
$basicDefaults = $settings['basic_defaults'];
$this->assertEquals('basic', $basicDefaults['type']);
$this->assertEquals(20, $basicDefaults['per_page']);
$this->assertEquals(15, $basicDefaults['per_page_mobile']);
$this->assertEquals(5, $basicDefaults['max_reply_depth']);
$this->assertEquals(10, $basicDefaults['max_comment_depth']);
$this->assertTrue($basicDefaults['use_comment']);
$this->assertTrue($basicDefaults['use_reply']);
$this->assertTrue($basicDefaults['show_view_count']);
$this->assertTrue($basicDefaults['notify_admin_on_post']);
$this->assertTrue($basicDefaults['notify_author']);
}
/**
* report_policy 카테고리의 기본값이 올바른지 확인
*/
public function test_get_all_settings_has_correct_report_policy(): void
{
$settings = $this->service->getAllSettings();
$reportPolicy = $settings['report_policy'];
$this->assertEquals(5, $reportPolicy['auto_hide_threshold']);
$this->assertEquals('both', $reportPolicy['auto_hide_target']);
$this->assertEquals(10, $reportPolicy['daily_report_limit']);
}
/**
* spam_security 카테고리의 기본값이 올바른지 확인
*/
public function test_get_all_settings_has_correct_spam_security(): void
{
$settings = $this->service->getAllSettings();
$spamSecurity = $settings['spam_security'];
$this->assertEquals(0, $spamSecurity['post_cooldown_seconds']);
$this->assertEquals(86400, $spamSecurity['view_count_cache_ttl']);
}
/**
* blocked_keywords가 basic_defaults에 포함되는지 확인
*/
public function test_get_all_settings_has_blocked_keywords_in_basic_defaults(): void
{
$settings = $this->service->getAllSettings();
$basicDefaults = $settings['basic_defaults'];
$this->assertArrayHasKey('blocked_keywords', $basicDefaults);
$this->assertIsArray($basicDefaults['blocked_keywords']);
$this->assertNotEmpty($basicDefaults['blocked_keywords']);
}
/**
* 결과가 캐싱되는지 확인 (두 번째 호출 시 동일 객체)
*/
public function test_get_all_settings_caches_result(): void
{
$first = $this->service->getAllSettings();
$second = $this->service->getAllSettings();
$this->assertSame($first, $second);
}
// ========================================
// getSettings (카테고리별) 테스트
// ========================================
/**
* 특정 카테고리 설정을 조회할 수 있는지 확인
*/
public function test_get_settings_returns_category_settings(): void
{
$basicDefaults = $this->service->getSettings('basic_defaults');
$this->assertIsArray($basicDefaults);
$this->assertArrayHasKey('per_page', $basicDefaults);
$this->assertEquals(20, $basicDefaults['per_page']);
}
/**
* 존재하지 않는 카테고리는 빈 배열 반환
*/
public function test_get_settings_returns_empty_for_unknown_category(): void
{
$result = $this->service->getSettings('nonexistent');
$this->assertIsArray($result);
$this->assertEmpty($result);
}
// ========================================
// getSetting (단일 키) 테스트
// ========================================
/**
* dot notation으로 설정값을 조회할 수 있는지 확인
*/
public function test_get_setting_with_dot_notation(): void
{
$perPage = $this->service->getSetting('basic_defaults.per_page');
$this->assertEquals(20, $perPage);
}
/**
* 존재하지 않는 키에 대해 기본값 반환
*/
public function test_get_setting_returns_default_for_unknown_key(): void
{
$result = $this->service->getSetting('basic_defaults.nonexistent', 'fallback');
$this->assertEquals('fallback', $result);
}
// ========================================
// setSetting 테스트
// ========================================
/**
* 개별 설정값을 저장할 수 있는지 확인
*/
public function test_set_setting_saves_value(): void
{
$result = $this->service->setSetting('basic_defaults.per_page', 30);
$this->assertTrue($result);
// 단건 저장이 자기 캐시를 비우므로 수동 초기화 없이 신값이 조회된다 (공개 #114 동종)
$perPage = $this->service->getSetting('basic_defaults.per_page');
$this->assertEquals(30, $perPage);
}
/**
* 설정이 파일에 실제로 저장되는지 확인
*/
public function test_set_setting_persists_to_file(): void
{
$this->service->setSetting('report_policy.auto_hide_threshold', 10);
$filePath = $this->storagePath.'/report_policy.json';
$this->assertFileExists($filePath);
$content = json_decode(File::get($filePath), true);
$this->assertEquals(10, $content['auto_hide_threshold']);
}
// ========================================
// saveSettings (전체 저장) 테스트
// ========================================
/**
* 여러 카테고리를 한 번에 저장할 수 있는지 확인
*/
public function test_save_settings_saves_multiple_categories(): void
{
$result = $this->service->saveSettings([
'basic_defaults' => [
'per_page' => 50,
'type' => 'gallery',
],
'report_policy' => [
'auto_hide_threshold' => 3,
],
]);
$this->assertTrue($result);
// 캐시 초기화 후 검증
$this->service->clearCache();
$settings = $this->service->getAllSettings();
$this->assertEquals(50, $settings['basic_defaults']['per_page']);
$this->assertEquals('gallery', $settings['basic_defaults']['type']);
$this->assertEquals(3, $settings['report_policy']['auto_hide_threshold']);
}
/**
* _meta 등 메타 키는 저장하지 않는지 확인
*/
public function test_save_settings_ignores_meta_keys(): void
{
$result = $this->service->saveSettings([
'_meta' => ['version' => '2.0.0'],
'_tab' => 'basic',
'basic_defaults' => ['per_page' => 25],
]);
$this->assertTrue($result);
// _meta 파일이 생성되지 않아야 함
$this->assertFileDoesNotExist($this->storagePath.'/_meta.json');
$this->assertFileDoesNotExist($this->storagePath.'/_tab.json');
}
/**
* 카테고리 값이 배열이 아닌 경우 무시되는지 확인
*/
public function test_save_settings_ignores_non_array_category_values(): void
{
$result = $this->service->saveSettings([
'basic_defaults' => ['per_page' => 25],
'invalid_key' => 'string_value',
]);
$this->assertTrue($result);
$this->assertFileDoesNotExist($this->storagePath.'/invalid_key.json');
}
/**
* 저장 후 캐시가 초기화되는지 확인
*/
public function test_save_settings_clears_cache(): void
{
// 캐시를 채움
$this->service->getAllSettings();
// 저장 후 캐시가 초기화되어야 함
$this->service->saveSettings([
'basic_defaults' => ['per_page' => 99],
]);
// 새로운 인스턴스처럼 동작해야 함
$settings = $this->service->getAllSettings();
$this->assertEquals(99, $settings['basic_defaults']['per_page']);
}
// ========================================
// getFrontendSettings 테스트
// ========================================
/**
* 프론트엔드 설정이 올바른 구조로 반환되는지 확인
*
* defaults.json의 frontend_schema 모든 카테고리가 expose: false이므로
* getFrontendSettings()는 빈 배열을 반환해야 합니다.
*/
public function test_get_frontend_settings_returns_exposed_categories(): void
{
$frontendSettings = $this->service->getFrontendSettings();
$this->assertIsArray($frontendSettings);
// 모든 카테고리가 expose: false → 프론트엔드에 노출되는 카테고리 없음
$this->assertEmpty($frontendSettings);
}
/**
* 프론트엔드 설정에 실제 값이 포함되는지 확인
*
* defaults.json의 frontend_schema 모든 카테고리가 expose: false이므로
* getFrontendSettings()는 빈 배열을 반환해야 합니다.
*/
public function test_get_frontend_settings_contains_actual_values(): void
{
$frontendSettings = $this->service->getFrontendSettings();
// 모든 카테고리가 expose: false → 빈 배열 반환
$this->assertIsArray($frontendSettings);
$this->assertEmpty($frontendSettings);
}
// ========================================
// clearCache 테스트
// ========================================
/**
* 캐시 초기화 후 fresh한 데이터를 가져오는지 확인
*/
public function test_clear_cache_forces_fresh_load(): void
{
// 첫 번째 조회로 캐시 생성
$first = $this->service->getAllSettings();
// 파일 직접 수정 (서비스를 경유하지 않아 캐시가 갱신되지 않는 상태를 만든다)
File::ensureDirectoryExists($this->storagePath);
File::put(
$this->storagePath.'/basic_defaults.json',
json_encode(['per_page' => 77], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
$this->assertEquals(
$first['basic_defaults']['per_page'],
$this->service->getAllSettings()['basic_defaults']['per_page'],
'캐시 초기화 전인데 파일 변경이 조회에 반영되었습니다.'
);
// 캐시 초기화
$this->service->clearCache();
// 수정된 값이 반영되어야 함
$settings = $this->service->getAllSettings();
$this->assertEquals(77, $settings['basic_defaults']['per_page']);
}
// ========================================
// 저장 후 기본값 병합 테스트
// ========================================
/**
* 저장된 설정이 기본값과 올바르게 병합되는지 확인
*
* 주의: saveSettings 는 Toggle/체크박스 OFF UX 대응으로
* 카테고리 범위 내 누락된 boolean 필드를 false 로 강제 저장한다
* (commit 6b46b1ce7 "Toggle/체크박스 저장 버그 수정"). 따라서 partial save
* 시 non-boolean 기본값만 유지되고 boolean 은 모두 false 가 된다.
* 이 테스트는 기본값 병합 로직의 non-boolean 경로만 검증한다.
*/
public function test_saved_settings_merge_with_defaults(): void
{
// 일부 필드만 저장
$this->service->saveSettings([
'basic_defaults' => ['per_page' => 40],
]);
$this->service->clearCache();
$settings = $this->service->getAllSettings();
// 저장된 값 반영
$this->assertEquals(40, $settings['basic_defaults']['per_page']);
// 저장되지 않은 non-boolean 기본값은 유지
$this->assertEquals('basic', $settings['basic_defaults']['type']);
$this->assertEquals(15, $settings['basic_defaults']['per_page_mobile']);
// 저장되지 않은 boolean 필드는 false 로 저장됨 (Toggle/체크박스 OFF UX 대응)
$this->assertFalse($settings['basic_defaults']['use_comment']);
}
/**
* default_board_permissions 기본값이 정상적으로 포함되는지 확인
*/
public function test_basic_defaults_includes_default_board_permissions(): void
{
$settings = $this->service->getAllSettings();
$permissions = $settings['basic_defaults']['default_board_permissions'] ?? null;
$this->assertNotNull($permissions);
$this->assertIsArray($permissions);
$this->assertArrayHasKey('posts.read', $permissions);
$this->assertContains('admin', $permissions['posts.read']);
$this->assertContains('guest', $permissions['posts.read']);
}
}