확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다. 그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다. 예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다. - 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용. 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다. - 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리. 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다. - 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다. - 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지 알 수 없었다. 시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects 검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다. 그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
193 lines
7.9 KiB
PHP
193 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use App\Services\SettingsService;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 코어 단건 설정 저장(setSetting)의 부수효과 정합 테스트 (공개 #114 동종, B-3)
|
|
*
|
|
* 단건 저장은 `PUT /api/admin/settings/{key}` 로 실제 도달 가능한 경로인데, 벌크 저장이
|
|
* 수행하는 부수효과 중 일부를 건너뛰었다:
|
|
*
|
|
* - `general.asset_url_mode` 변경 시 SEO 프리렌더 캐시 삭제
|
|
* - `drivers` 카테고리 저장 시 큐 워커 재시작 신호
|
|
*
|
|
* 벌크 위임은 채택하지 않는다 — 입력 shape 이 다르고(벌크는 화면 키 + reverseFrontendKeys
|
|
* 전제, 단건은 원본 dot-key), 벌크의 shallow `array_merge` 로는 `identity.purpose_providers.X.Y`
|
|
* 같은 깊은 키를 저장할 때 형제 매핑이 통째로 소실된다. 부수효과만 이식한다.
|
|
*/
|
|
class SettingsServiceSetSettingSideEffectsTest extends TestCase
|
|
{
|
|
/**
|
|
* ConfigRepository/AttachmentRepository 를 mock 한 SettingsService 를 만듭니다.
|
|
*
|
|
* @param mixed $existingValue `get()` 이 돌려줄 기존 값
|
|
* @param bool $setResult `set()` 의 반환값
|
|
* @return SettingsService 대상 서비스
|
|
*/
|
|
private function makeService(mixed $existingValue = null, bool $setResult = true): SettingsService
|
|
{
|
|
$configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
|
$configRepository->shouldReceive('get')->andReturn($existingValue);
|
|
$configRepository->shouldReceive('set')->andReturn($setResult);
|
|
|
|
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
|
$this->mock(CacheInterface::class)->shouldIgnoreMissing();
|
|
|
|
return app(SettingsService::class);
|
|
}
|
|
|
|
/**
|
|
* 자산 URL 방식을 단건으로 바꾸면 SEO 프리렌더 캐시가 삭제된다. (실패-먼저)
|
|
*
|
|
* @effects seo_prerender_cache_cleared_on_asset_url_mode_change
|
|
*/
|
|
public function test_asset_url_mode_change_clears_seo_cache(): void
|
|
{
|
|
$this->mock(SeoCacheManagerInterface::class)
|
|
->shouldReceive('clearAll')
|
|
->once();
|
|
|
|
$service = $this->makeService(existingValue: 'relative');
|
|
|
|
$this->assertTrue($service->setSetting('general.asset_url_mode', 'absolute'));
|
|
}
|
|
|
|
/**
|
|
* 값이 그대로면 SEO 캐시를 건드리지 않는다.
|
|
*
|
|
* @effects seo_prerender_cache_untouched_when_value_unchanged
|
|
*/
|
|
public function test_same_asset_url_mode_does_not_clear_seo_cache(): void
|
|
{
|
|
$this->mock(SeoCacheManagerInterface::class)
|
|
->shouldReceive('clearAll')
|
|
->never();
|
|
|
|
$service = $this->makeService(existingValue: 'absolute');
|
|
|
|
$this->assertTrue($service->setSetting('general.asset_url_mode', 'absolute'));
|
|
}
|
|
|
|
/**
|
|
* 다른 키 저장은 SEO 프리렌더 캐시를 건드리지 않는다.
|
|
*
|
|
* @effects seo_prerender_cache_untouched_when_value_unchanged
|
|
*/
|
|
public function test_unrelated_key_does_not_clear_seo_cache(): void
|
|
{
|
|
$this->mock(SeoCacheManagerInterface::class)
|
|
->shouldReceive('clearAll')
|
|
->never();
|
|
|
|
$service = $this->makeService(existingValue: 'old');
|
|
|
|
$this->assertTrue($service->setSetting('general.site_name', 'new'));
|
|
}
|
|
|
|
/**
|
|
* drivers 카테고리 단건 저장은 큐 워커 재시작 신호를 보낸다. (실패-먼저)
|
|
*
|
|
* @effects queue_workers_restarted_on_driver_change
|
|
*/
|
|
public function test_driver_setting_restarts_queue_workers(): void
|
|
{
|
|
Artisan::shouldReceive('call')->with('config:clear')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('config:cache')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('queue:restart')->once();
|
|
|
|
$service = $this->makeService(existingValue: 'sync');
|
|
|
|
$this->assertTrue($service->setSetting('drivers.queue_driver', 'redis'));
|
|
}
|
|
|
|
/**
|
|
* drivers 외 카테고리는 큐 워커를 재시작하지 않는다.
|
|
*
|
|
* @effects queue_workers_not_restarted_for_other_categories
|
|
*/
|
|
public function test_non_driver_setting_does_not_restart_queue_workers(): void
|
|
{
|
|
Artisan::shouldReceive('call')->with('config:clear')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('config:cache')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('queue:restart')->never();
|
|
|
|
$service = $this->makeService(existingValue: 'old');
|
|
|
|
$this->assertTrue($service->setSetting('general.site_name', 'new'));
|
|
}
|
|
|
|
/**
|
|
* 저장 실패 시에는 부수효과가 실행되지 않는다.
|
|
*
|
|
* @effects side_effects_skipped_when_save_fails
|
|
*/
|
|
public function test_failed_save_skips_side_effects(): void
|
|
{
|
|
Artisan::shouldReceive('call')->with('config:clear')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('config:cache')->zeroOrMoreTimes();
|
|
Artisan::shouldReceive('call')->with('queue:restart')->never();
|
|
|
|
$this->mock(SeoCacheManagerInterface::class)
|
|
->shouldReceive('clearAll')
|
|
->never();
|
|
|
|
$service = $this->makeService(existingValue: 'sync', setResult: false);
|
|
|
|
$this->assertFalse($service->setSetting('drivers.queue_driver', 'redis'));
|
|
}
|
|
|
|
/**
|
|
* 단건 저장은 받은 키를 변형 없이 저장소에 넘긴다. (벌크 위임 금지 — 계약 고정)
|
|
*
|
|
* 벌크 저장은 입력을 탭/필드로 쪼개 `reverseFrontendKeys()`(화면 키 → 저장소 키)를 태운 뒤
|
|
* shallow `array_merge` 로 병합한다. 단건 경로를 벌크에 위임하면 두 가지가 깨진다.
|
|
*
|
|
* - 저장소 키를 직접 넘기는 프로그램 호출자(본인인증 플러그인 설치/삭제의
|
|
* `identity.purpose_providers.*`)의 키가 역변환 대상에 걸릴 수 있다.
|
|
* - shallow merge 는 깊은 키를 평평한 문자열 키로 취급해 형제 provider 매핑을 통째로
|
|
* 지운다 — 다른 목적(purpose)의 인증수단 설정이 조용히 사라진다.
|
|
*
|
|
* 그래서 단건 경로는 받은 키를 그대로 `ConfigRepository::set()` 에 넘긴다.
|
|
* 이 테스트는 위임·키 분해·역변환 중 어느 것이 끼어도 실패한다.
|
|
*
|
|
* @effects single_key_save_uses_storage_key_verbatim
|
|
*/
|
|
public function test_single_key_save_passes_the_storage_key_verbatim(): void
|
|
{
|
|
Artisan::shouldReceive('call')->zeroOrMoreTimes();
|
|
|
|
$written = [];
|
|
$configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing();
|
|
$configRepository->shouldReceive('get')->andReturn(null);
|
|
$configRepository->shouldReceive('set')->andReturnUsing(function (string $key, mixed $value) use (&$written): bool {
|
|
$written[] = $key;
|
|
|
|
return true;
|
|
});
|
|
|
|
$this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing();
|
|
$this->mock(CacheInterface::class)->shouldIgnoreMissing();
|
|
|
|
$service = app(SettingsService::class);
|
|
|
|
// ① 화면 키가 따로 있는 저장소 키 — 역변환이 끼면 `cache.cache_enabled` 가 된다
|
|
$service->setSetting('cache.enabled', true);
|
|
// ② 플러그인이 실제로 넘기는 깊은 키 — 형제 provider 매핑이 보존되어야 하는 자리
|
|
$service->setSetting('identity.purpose_providers.signup.sms', 'nhnkcp');
|
|
|
|
$this->assertSame(
|
|
['cache.enabled', 'identity.purpose_providers.signup.sms'],
|
|
$written,
|
|
'단건 저장이 받은 키를 변형했습니다 — 프로그램 호출자가 엉뚱한 키에 저장됩니다.'
|
|
);
|
|
}
|
|
}
|