- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다 - config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단 - custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장 - 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제) - 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비 - sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트 - 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
329 lines
14 KiB
PHP
329 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Repositories\AttachmentRepositoryInterface;
|
|
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
|
use App\Extension\Traits\ClearsTemplateCaches;
|
|
use App\Seo\Contracts\SeoCacheManagerInterface;
|
|
use App\Services\SettingsService;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Cache;
|
|
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'));
|
|
}
|
|
|
|
/**
|
|
* 자산 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,
|
|
'단건 저장이 받은 키를 변형했습니다 — 프로그램 호출자가 엉뚱한 키에 저장됩니다.'
|
|
);
|
|
}
|
|
}
|