확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다. 그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다. 예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다. - 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용. 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다. - 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리. 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다. - 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다. - 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지 알 수 없었다. 시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects 검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다. 그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
254 lines
9.7 KiB
PHP
254 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Unit;
|
|
|
|
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
|
use App\Models\NotificationDefinition;
|
|
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;
|
|
|
|
/**
|
|
* 게시판 단건 설정 저장(setSetting)의 파이프라인 경유 / 캐시 정합 테스트 (공개 #114 동종)
|
|
*
|
|
* 이커머스와 같은 결함에 더해 한 단계 더 나쁜 상태였다 — 저장 후 자기 캐시(`$settings`)를
|
|
* 비우지 않아, 같은 요청 안에서 곧이은 조회가 저장 전 값을 반환했다. 기존 테스트는
|
|
* `clearCache()` 를 수동으로 끼워 넣어 그 결함을 우회하고 있었다.
|
|
*
|
|
* 벌크 저장의 boolean backfill 은 폼 Toggle-OFF 미전송 대응이라 단건 저장에 그대로 태우면
|
|
* 안 된다 — 제출하지 않은 boolean 이 전부 false 로 박제되어 기본값 true 인 항목이 뒤집힌다.
|
|
* 그래서 공통 본문만 공유하고 backfill 여부만 갈라 놓는다.
|
|
*/
|
|
class BoardSetSettingPipelineTest extends ModuleTestCase
|
|
{
|
|
private BoardSettingsService $service;
|
|
|
|
private string $storagePath;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$permissionService = $this->createMock(BoardPermissionService::class);
|
|
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
|
|
$notificationDefinitionRepository->method('getByExtension')->willReturn(new Collection);
|
|
|
|
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
|
|
$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();
|
|
}
|
|
|
|
/**
|
|
* 카테고리 저장 파일을 그대로 읽습니다.
|
|
*
|
|
* @param string $category 카테고리명
|
|
* @return array 저장 파일의 디코드 결과 (파일 부재 시 빈 배열)
|
|
*/
|
|
private function savedFile(string $category): array
|
|
{
|
|
$path = $this->storagePath.'/'.$category.'.json';
|
|
|
|
if (! File::exists($path)) {
|
|
return [];
|
|
}
|
|
|
|
return json_decode(File::get($path), true) ?? [];
|
|
}
|
|
|
|
/**
|
|
* 단건 저장 직후 같은 인스턴스의 조회가 신값을 반환한다. (실패-먼저)
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects single_key_save_invalidates_service_cache
|
|
*/
|
|
public function test_single_key_save_invalidates_cache_without_manual_clear(): void
|
|
{
|
|
// 조회로 캐시를 채운 뒤 저장
|
|
$this->assertNotSame(31, $this->service->getSetting('basic_defaults.per_page'));
|
|
|
|
$this->service->setSetting('basic_defaults.per_page', 31);
|
|
|
|
$this->assertSame(
|
|
31,
|
|
$this->service->getSetting('basic_defaults.per_page'),
|
|
'저장 후에도 조회가 저장 전 값을 반환했습니다 (캐시 무효화 누락).'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단건 저장은 미제출 boolean 을 false 로 박제하지 않는다.
|
|
*
|
|
* 벌크 저장의 backfill 은 폼 전체 제출을 전제로 한 보정이므로, 부분 저장에 태우면
|
|
* 기본값 true 인 항목이 통째로 뒤집힌다.
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects single_key_save_does_not_backfill_booleans
|
|
*/
|
|
public function test_single_key_save_does_not_backfill_unsubmitted_booleans(): void
|
|
{
|
|
$this->service->setSetting('basic_defaults.per_page', 25);
|
|
|
|
$saved = $this->savedFile('basic_defaults');
|
|
|
|
$this->assertArrayNotHasKey(
|
|
'use_comment',
|
|
$saved,
|
|
'제출하지 않은 boolean 이 단건 저장으로 박제되었습니다.'
|
|
);
|
|
$this->assertTrue(
|
|
$this->service->getSetting('basic_defaults.use_comment'),
|
|
'기본값 true 인 boolean 설정이 단건 저장으로 뒤집혔습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 벌크 저장의 boolean backfill 은 그대로 유지된다. (비회귀 pin)
|
|
*
|
|
* @scenario save_path=bulk
|
|
*
|
|
* @effects bulk_save_backfills_unsubmitted_booleans
|
|
*/
|
|
public function test_bulk_save_still_backfills_unsubmitted_booleans(): void
|
|
{
|
|
$this->service->saveSettings(['basic_defaults' => ['per_page' => 25]]);
|
|
|
|
$saved = $this->savedFile('basic_defaults');
|
|
|
|
$this->assertArrayHasKey('use_comment', $saved, '벌크 저장의 boolean backfill 이 사라졌습니다.');
|
|
$this->assertFalse($saved['use_comment']);
|
|
}
|
|
|
|
/**
|
|
* 신고 정책 단건 저장도 알림 강제 활성 규칙을 거친다. (실패-먼저)
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects report_notification_flags_forced_on_single_key_save
|
|
*/
|
|
public function test_single_key_save_forces_report_notification_flags(): void
|
|
{
|
|
$this->service->setSetting('report_policy', [
|
|
'auto_hide_threshold' => 7,
|
|
'notify_admin_on_report' => false,
|
|
'notify_author_on_report_action' => false,
|
|
]);
|
|
|
|
$saved = $this->savedFile('report_policy');
|
|
|
|
$this->assertTrue(
|
|
$saved['notify_admin_on_report'] ?? false,
|
|
'신고 알림 강제 활성 규칙이 단건 저장 경로에서 적용되지 않았습니다.'
|
|
);
|
|
$this->assertTrue($saved['notify_author_on_report_action'] ?? false);
|
|
$this->assertSame(7, $saved['auto_hide_threshold'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* 신고 정책 단건 저장은 알림 정의의 활성 상태까지 동기화한다.
|
|
*
|
|
* 저장 파일만 보면 강제 활성 규칙이 통과한 것처럼 보인다. 그러나 알림 정의가 비활성인
|
|
* 채로 남으면 `NotificationHookListener` 가 훅을 구독하지 않아 설정은 켜져 있는데
|
|
* 알림만 오지 않는다 — 화면에도 로그에도 흔적이 없다.
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects report_notification_flags_forced_on_single_key_save
|
|
*/
|
|
public function test_single_key_save_syncs_notification_definition_status(): void
|
|
{
|
|
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
|
|
$definition->is_active = false;
|
|
|
|
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
|
|
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
|
|
$repository->expects($this->once())
|
|
->method('update')
|
|
->with($definition, ['is_active' => true]);
|
|
|
|
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
|
|
|
|
$service->setSetting('report_policy', [
|
|
'auto_hide_threshold' => 7,
|
|
'notify_admin_on_report' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 이미 원하는 상태인 알림 정의는 다시 쓰지 않는다. (불필요 쓰기 차단)
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects report_notification_flags_forced_on_single_key_save
|
|
*/
|
|
public function test_single_key_save_skips_notification_definition_update_when_already_active(): void
|
|
{
|
|
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
|
|
$definition->is_active = true;
|
|
|
|
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
|
|
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
|
|
$repository->expects($this->never())->method('update');
|
|
|
|
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
|
|
|
|
$service->setSetting('report_policy', [
|
|
'auto_hide_threshold' => 7,
|
|
'notify_admin_on_report' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 단건 저장도 defaults 스키마 기준 숫자 정규화를 거친다. (실패-먼저)
|
|
*
|
|
* @scenario save_path=single_key
|
|
*
|
|
* @effects numeric_string_normalized_on_single_key_save
|
|
*/
|
|
public function test_single_key_save_normalizes_numeric_strings(): void
|
|
{
|
|
$this->service->setSetting('basic_defaults.per_page', '42');
|
|
|
|
$saved = $this->savedFile('basic_defaults');
|
|
|
|
$this->assertSame(42, $saved['per_page'] ?? null, '숫자 문자열이 정규화되지 않고 저장되었습니다.');
|
|
}
|
|
|
|
/**
|
|
* 테스트 실행 중에는 운영 설정 경로를 쓰지 않는다. (실패-먼저)
|
|
*
|
|
* @effects board_settings_storage_isolated_in_tests
|
|
*/
|
|
public function test_storage_path_is_isolated_during_tests(): void
|
|
{
|
|
$productionPath = storage_path('app/modules/sirsoft-board/settings/basic_defaults.json');
|
|
// 운영 파일의 존재 여부·내용을 그대로 스냅샷 (환경마다 상태가 다르므로 변화 없음만 단언)
|
|
$before = File::exists($productionPath) ? File::get($productionPath) : null;
|
|
|
|
$this->service->setSetting('basic_defaults.per_page', 33);
|
|
|
|
$this->assertFileExists(
|
|
$this->storagePath.'/basic_defaults.json',
|
|
'테스트 격리 경로에 저장되지 않았습니다.'
|
|
);
|
|
|
|
$after = File::exists($productionPath) ? File::get($productionPath) : null;
|
|
$this->assertSame($before, $after, '테스트가 운영 설정 파일을 생성/변경했습니다.');
|
|
}
|
|
}
|