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')); } /** * 자산 URL 방식을 단건으로 바꾸면 확장 캐시 버전이 오른다 — 실제 키 관측 (#651 F5). * * 병합 번들 CSS 에는 그 시점의 자산 URL 형태(확장자 / `?file=`)가 구워져 있고, 디스크 번들과 * 정적 게시본은 캐시 버전으로 키드된다. 버전이 오르지 않으면 모드를 바꿔도 옛 형태의 * `url()` 이 남아 글꼴·이미지가 404 가 된다. * * @effects asset_url_mode_change_bumps_extension_cache_version */ public function test_asset_url_mode_change_bumps_extension_cache_version(): void { $this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing(); Artisan::shouldReceive('call')->zeroOrMoreTimes(); ClearsTemplateCaches::resetExtensionCacheStoreMemo(); Cache::put('g7:core:ext.cache_version', 1000); $service = $this->makeService(existingValue: 'extensionless'); $this->assertTrue($service->setSetting('general.asset_url_mode', 'extension')); $this->assertGreaterThan( 1000, (int) Cache::get('g7:core:ext.cache_version'), '자산 URL 방식 단건 변경이 확장 캐시 버전을 올리지 않았다 — 번들 CSS 의 옛 url() 형태가 남는다.' ); } /** * 값이 그대로면 확장 캐시 버전도 그대로다. * * @effects asset_url_mode_change_bumps_extension_cache_version */ public function test_same_asset_url_mode_keeps_extension_cache_version(): void { $this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing(); Artisan::shouldReceive('call')->zeroOrMoreTimes(); ClearsTemplateCaches::resetExtensionCacheStoreMemo(); Cache::put('g7:core:ext.cache_version', 1000); $service = $this->makeService(existingValue: 'extension'); $this->assertTrue($service->setSetting('general.asset_url_mode', 'extension')); $this->assertSame(1000, (int) Cache::get('g7:core:ext.cache_version')); } /** * 벌크 저장(saveSettings) 경로도 같은 부수효과를 갖는다 — 화면 [저장] 버튼이 타는 경로다. * * @effects asset_url_mode_change_bumps_extension_cache_version */ public function test_bulk_save_asset_url_mode_change_bumps_extension_cache_version(): void { $this->mock(SeoCacheManagerInterface::class)->shouldIgnoreMissing(); Artisan::shouldReceive('call')->zeroOrMoreTimes(); $existing = ['asset_url_mode' => 'extensionless']; $configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing(); $configRepository->shouldReceive('getCategory')->with('general')->andReturnUsing(function () use (&$existing): array { return $existing; }); $configRepository->shouldReceive('saveCategory')->andReturn(true); $this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing(); $this->mock(CacheInterface::class)->shouldIgnoreMissing(); ClearsTemplateCaches::resetExtensionCacheStoreMemo(); Cache::put('g7:core:ext.cache_version', 1000); $service = app(SettingsService::class); $this->assertTrue($service->saveSettings(['_tab' => 'general', 'general' => ['asset_url_mode' => 'extension']])); $this->assertGreaterThan(1000, (int) Cache::get('g7:core:ext.cache_version')); // 같은 값 재저장은 올리지 않는다 Cache::put('g7:core:ext.cache_version', 2000); $existing = ['asset_url_mode' => 'extension']; $this->assertTrue($service->saveSettings(['_tab' => 'general', 'general' => ['asset_url_mode' => 'extension']])); $this->assertSame(2000, (int) Cache::get('g7:core:ext.cache_version')); } /** * 설정 복원으로 자산 URL 방식이 바뀌면 SEO 캐시 삭제 + 확장 캐시 버전 bump 가 따른다. * * 복원은 설정 전체를 갈아엎는 쓰기라 두 저장 경로와 같은 부수효과를 받아야 한다 — * 종전에는 변경 감지 자체가 없었다. * * @effects asset_url_mode_change_bumps_extension_cache_version */ public function test_restore_asset_url_mode_change_bumps_extension_cache_version(): void { $this->mock(SeoCacheManagerInterface::class)->shouldReceive('clearAll')->once(); Artisan::shouldReceive('call')->zeroOrMoreTimes(); $configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing(); // 복원 전 / 복원 후 순서대로 읽힌다 $configRepository->shouldReceive('get')->with('general.asset_url_mode')->andReturn('extensionless', 'extension'); $configRepository->shouldReceive('restore')->andReturn(true); $this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing(); $this->mock(CacheInterface::class)->shouldIgnoreMissing(); ClearsTemplateCaches::resetExtensionCacheStoreMemo(); Cache::put('g7:core:ext.cache_version', 1000); $service = app(SettingsService::class); $this->assertTrue($service->restoreSettings('/tmp/backup.json')); $this->assertGreaterThan(1000, (int) Cache::get('g7:core:ext.cache_version')); } /** * 복원 전후 값이 같으면 두 부수효과 모두 없다. * * @effects asset_url_mode_change_bumps_extension_cache_version */ public function test_restore_with_same_asset_url_mode_has_no_side_effects(): void { $this->mock(SeoCacheManagerInterface::class)->shouldReceive('clearAll')->never(); Artisan::shouldReceive('call')->zeroOrMoreTimes(); $configRepository = $this->mock(ConfigRepositoryInterface::class)->shouldIgnoreMissing(); $configRepository->shouldReceive('get')->with('general.asset_url_mode')->andReturn('extensionless'); $configRepository->shouldReceive('restore')->andReturn(true); $this->mock(AttachmentRepositoryInterface::class)->shouldIgnoreMissing(); $this->mock(CacheInterface::class)->shouldIgnoreMissing(); ClearsTemplateCaches::resetExtensionCacheStoreMemo(); Cache::put('g7:core:ext.cache_version', 1000); $service = app(SettingsService::class); $this->assertTrue($service->restoreSettings('/tmp/backup.json')); $this->assertSame(1000, (int) Cache::get('g7:core:ext.cache_version')); } /** * 단건 저장은 받은 키를 변형 없이 저장소에 넘긴다. (벌크 위임 금지 — 계약 고정) * * 벌크 저장은 입력을 탭/필드로 쪼개 `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, '단건 저장이 받은 키를 변형했습니다 — 프로그램 호출자가 엉뚱한 키에 저장됩니다.' ); } }