Files
Gnuboard7/tests/Unit/Services/SettingsServiceSetSettingSideEffectsTest.php
T
HeuJung 0c4f52fc96 fix(core): 업데이트 트리 argv 판정을 명령줄 SAPI 로 한정하고 매니페스트 삭제 실패를 로그에 남김
7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.

1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
 쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
 실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
 패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
 모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
 두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
 더했다.

2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
 경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
 불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
 이 경고가 원인이 권한이라는 유일한 흔적이다.

3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
 전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
 실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.

관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.

4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
 앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
 문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
 같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.

 그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
 없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
 않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
 잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.

 9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
 한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
 baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
 더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
 등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
2026-09-08 10:42:19 +09:00

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;
/**
* [case:cache-18] 코어 단건 설정 저장(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,
'단건 저장이 받은 키를 변형했습니다 — 프로그램 호출자가 엉뚱한 키에 저장됩니다.'
);
}
}