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