Files
Gnuboard7/tests/Unit/Extension/ClearsTemplateCachesTest.php
T
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 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 언어팩 동기
2026-09-06 15:22:52 +09:00

369 lines
15 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* ClearsTemplateCaches trait 테스트
*
* 모듈/플러그인 활성화/비활성화 시 캐시 무효화 기능을 테스트합니다.
*/
class ClearsTemplateCachesTest extends TestCase
{
use RefreshDatabase;
/**
* trait을 사용하는 테스트용 클래스
*/
private object $traitUser;
protected function setUp(): void
{
parent::setUp();
// trait을 사용하는 익명 클래스 생성
$this->traitUser = new class
{
use ClearsTemplateCaches;
// protected 메서드를 public으로 노출
public function callIncrementExtensionCacheVersion(): void
{
$this->incrementExtensionCacheVersion();
}
public function callClearAllTemplateLanguageCaches(): void
{
$this->clearAllTemplateLanguageCaches();
}
public function callClearAllTemplateRoutesCaches(): void
{
$this->clearAllTemplateRoutesCaches();
}
};
// 스토어 메모 초기화 — 본 테스트는 기본 스토어(config('cache.default')) 기준으로
// 버전을 검증하므로, 직전 테스트가 다른 스토어로 메모이즈했을 수 있어 리셋한다.
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
// 캐시 초기화 — 확장 좌표 키가 들어가는 스토어를 명시적으로 비운다(메모 고정 스토어).
Cache::store(config('cache.default'))->flush();
Cache::flush();
}
/**
* 캐시 버전 증가 테스트
*/
public function test_increment_extension_cache_version(): void
{
// 캐시 버전 증가
$beforeTime = time();
$this->traitUser->callIncrementExtensionCacheVersion();
$afterTime = time();
// 캐시 버전이 현재 타임스탬프로 설정됨
$version = ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertGreaterThanOrEqual($beforeTime, $version);
$this->assertLessThanOrEqual($afterTime, $version);
}
/**
* 캐시 버전 연속 증가 테스트
*/
public function test_increment_extension_cache_version_multiple_times(): void
{
// 첫 번째 증가
$this->traitUser->callIncrementExtensionCacheVersion();
$firstVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 1초 대기 (time() 기반이므로)
sleep(1);
// 두 번째 증가
$this->traitUser->callIncrementExtensionCacheVersion();
$secondVersion = ClearsTemplateCaches::getExtensionCacheVersion();
// 두 번째 버전이 첫 번째보다 크거나 같음 (동일 초 내에서는 같을 수 있음)
$this->assertGreaterThanOrEqual($firstVersion, $secondVersion);
}
/**
* getExtensionCacheVersion 정적 메서드가 키 부재 시 새 유효 버전을 재생성한다.
*
* 회귀 가드 (버그 ②): 과거에는 키 부재(`cache:clear` 로 소실) 시 무효값 0 을
* 반환하여 모든 자원 URL 이 `?v=0` 으로 수렴 → 과거 `?v=0` 에 immutable 로 박힌
* 구버전 에셋이 stale 고착("Unknown action handler" 회귀). 이제 키가 없으면
* 새 `time()` 버전을 생성·저장·반환해 프론트가 새 URL 로 요청하도록 한다.
*/
public function test_get_extension_cache_version_regenerates_when_not_set(): void
{
Cache::forget('g7:core:ext.cache_version');
$beforeTime = time();
$version = ClearsTemplateCaches::getExtensionCacheVersion();
$afterTime = time();
// 0 이 아닌 유효한 타임스탬프 반환 (기존 결함이면 0 → 실패)
$this->assertGreaterThanOrEqual($beforeTime, $version);
$this->assertLessThanOrEqual($afterTime, $version);
// 생성된 버전이 캐시에 저장됨 — 다음 호출이 같은 값을 반환
$this->assertSame($version, ClearsTemplateCaches::getExtensionCacheVersion());
}
/**
* 캐시 전체 비우기(cache:clear) 후에도 유효 버전이 재생성된다.
*
* 회귀 가드 (버그 ②): `php artisan cache:clear` 가 `ext.cache_version` 키를 함께
* 삭제하면 0 이 내려가 자원 URL 이 `?v=0` 으로 붕괴하던 결함. 이제 키가 비어도
* 다음 조회에서 새 유효 버전을 재생성하므로 stale 고착이 발생하지 않는다.
*/
public function test_version_survives_cache_clear(): void
{
// 버전 bump 후 양수 확인
$this->traitUser->callIncrementExtensionCacheVersion();
$this->assertGreaterThan(0, ClearsTemplateCaches::getExtensionCacheVersion());
// cache:clear 시뮬레이션 — 키 소실
Cache::store(config('cache.default'))->flush();
Cache::flush();
// 재생성되어 여전히 양수 (0 붕괴 차단)
$this->assertGreaterThan(0, ClearsTemplateCaches::getExtensionCacheVersion());
}
/**
* getExtensionCacheVersion 정적 메서드가 캐시된 값 반환
*/
public function test_get_extension_cache_version_returns_cached_value(): void
{
// 확장 기능 캐시 버전은 코어 소유 키이므로 항상 `g7:core:` 네임스페이스에 저장된다.
// 모듈/플러그인 테스트가 CacheInterface 를 PluginCacheDriver 등으로 재바인딩해도
// getExtensionCacheVersion() 은 CoreCacheDriver 를 통해 동일 키를 조회해야 한다.
$expectedVersion = 1735000000;
Cache::put('g7:core:ext.cache_version', $expectedVersion);
$version = ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertEquals($expectedVersion, $version);
}
/**
* CacheInterface 가 PluginCacheDriver 로 재바인딩된 상태에서도
* 확장 기능 캐시 버전은 코어 네임스페이스(`g7:core:`)를 사용해야 한다.
*
* 회귀 방지: 과거 resolveExtensionCache() 가 컨테이너의 CacheInterface
* 바인딩을 그대로 사용해, 선행 모듈/플러그인 테스트가 남긴 PluginCacheDriver
* 바인딩 때문에 `g7:plugin:` 네임스페이스로 읽혀 버전을 0 으로 오인하던 결함.
*/
public function test_extension_cache_version_uses_core_namespace_despite_rebound_cache_interface(): void
{
// 다른 테스트가 컨테이너 CacheInterface 를 플러그인 드라이버로 재바인딩한 상황 모사
$this->app->instance(
CacheInterface::class,
new PluginCacheDriver('sirsoft-payment', config('cache.default', 'array')),
);
$expectedVersion = 1735000000;
Cache::put('g7:core:ext.cache_version', $expectedVersion);
// 트레이트는 재바인딩과 무관하게 CoreCacheDriver 로 코어 키를 조회한다.
$this->assertEquals($expectedVersion, ClearsTemplateCaches::getExtensionCacheVersion());
// incrementExtensionCacheVersion() 도 코어 네임스페이스에 기록한다.
$this->traitUser->callIncrementExtensionCacheVersion();
$written = Cache::get('g7:core:ext.cache_version');
$this->assertEquals($written, ClearsTemplateCaches::getExtensionCacheVersion());
}
/**
* clearAllTemplateLanguageCaches() 는 버전 기반 설계로 리팩토링되어 레거시 no-op.
*
* 2026-03-31 커밋 c86c6d3e5 에서 실제 캐시 삭제 로직 제거. 대신
* incrementExtensionCacheVersion() 으로 버전 카운터가 증가하고,
* 프론트엔드는 새 버전의 캐시 키로 API를 재요청하여 실질적 무효화가 이루어짐.
* 이전 버전 캐시는 TTL 로 자연 만료.
*
* 본 테스트는 현재 동작(호출 호환성 유지, 버전 카운터 증가)을 검증한다.
*/
public function test_clear_all_template_language_caches_is_no_op_but_triggers_version_bump(): void
{
$identifier = 'test-template-'.uniqid();
$template = Template::create([
'identifier' => $identifier,
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
Cache::put("template.language.{$template->identifier}.ko", ['key' => 'value']);
$this->assertTrue(Cache::has("template.language.{$template->identifier}.ko"));
// clearAllTemplateLanguageCaches 자체는 no-op (호출 호환성 유지용)
// 예외 없이 호출 가능해야 함
$this->traitUser->callClearAllTemplateLanguageCaches();
// 기존 캐시는 그대로 유지 — 버전 기반 무효화는 프론트엔드가 새 버전 키로 요청하여 처리
$this->assertTrue(
Cache::has("template.language.{$template->identifier}.ko"),
'clearAllTemplateLanguageCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
);
}
public function test_clear_all_template_routes_caches_is_no_op(): void
{
$identifier = 'test-template-'.uniqid();
Template::create([
'identifier' => $identifier,
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
Cache::put("template.routes.{$identifier}", ['routes' => []]);
$this->assertTrue(Cache::has("template.routes.{$identifier}"));
// no-op 호출 — 예외 없이 성공해야 함
$this->traitUser->callClearAllTemplateRoutesCaches();
// 레거시 캐시는 유지됨
$this->assertTrue(
Cache::has("template.routes.{$identifier}"),
'clearAllTemplateRoutesCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
);
}
/**
* incrementExtensionCacheVersion 호출 후 getExtensionCacheVersion 이 증가된 타임스탬프 반환.
*
* 활성/비활성 템플릿 구분은 더 이상 trait 수준에서 다루지 않으며
* (레거시 테스트가 기대한 "활성만 삭제" 로직은 제거되었음),
* 실제 무효화는 모든 프론트엔드 요청이 새 버전 파라미터를 사용하게 함으로써 이루어진다.
*/
public function test_increment_extension_cache_version_is_actual_invalidation_mechanism(): void
{
// 활성/비활성 템플릿 생성
Template::create([
'identifier' => 'active-template',
'vendor' => 'test',
'name' => ['ko' => '활성', 'en' => 'Active'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
Template::create([
'identifier' => 'inactive-template',
'vendor' => 'test',
'name' => ['ko' => '비활성', 'en' => 'Inactive'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 버전 증가
$this->traitUser->callIncrementExtensionCacheVersion();
// 타임스탬프로 설정됨 (양수)
$newVersion = ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertGreaterThan(0, $newVersion);
$this->assertLessThanOrEqual(time(), $newVersion);
}
/**
* 캐시 버전 키는 만료되지 않는다 (#651 F11).
*
* 종전에는 `put()` 에 TTL 을 넘기지 않아 `cache.default_ttl`(86400) 로 **매일 만료**됐고,
* 다음 독자가 `regenerateExtensionCacheVersion()` 으로 새 버전을 만들어 정적 게시본 전체
* (715파일·40MB)를 재생성하고 전 방문자의 자산 URL 을 바꿨다. 재생성은 `cache:clear` 나
* 스토어 소실 때만 일어나야 한다.
*
* @effects cache_version_key_does_not_expire
*/
public function test_version_key_survives_two_days(): void
{
$this->traitUser->callIncrementExtensionCacheVersion();
$version = ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertGreaterThan(0, $version);
try {
Carbon::setTestNow(now()->addDays(2));
$this->assertSame(
$version,
(int) Cache::get('g7:core:ext.cache_version'),
'캐시 버전 키가 이틀 뒤 만료됐다 — 매일 정적 게시본 전체가 재생성되고 전 방문자의 자산 URL 이 바뀐다'
);
$this->assertSame($version, ClearsTemplateCaches::getExtensionCacheVersion());
} finally {
Carbon::setTestNow();
}
}
/**
* 영구 TTL 상수는 `put(…, 0)`(forget) 도, 기본 TTL 도 아닌 명시 양수여야 한다.
*
* @effects cache_version_key_does_not_expire
*/
public function test_persistent_ttl_constant_is_positive_and_longer_than_default(): void
{
$this->assertGreaterThan(
(int) config('cache.default_ttl', 86400) * 365,
ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS
);
}
/**
* 버전 **재생성** 경로도 정적 게시를 예약한다 (D1).
*
* 회귀 가드: `incrementExtensionCacheVersion()` 에만 예약이 있고 재생성 경로에는
* 없었다. 그래서 `cache:clear` 후 첫 호출자가 CLI 면 포인터만 새 버전으로 점프하고
* 산출물은 옛 버전에 남았다 — "캐시 버전 갱신 → 게시" 단일 지점 규율의 유일한 구멍.
* 스케줄 GC 가 바로 그 첫 호출자라 매일 재현됐다.
*
* @effects regenerate_path_schedules_publish
*/
public function test_regenerate_path_schedules_static_publish(): void
{
$this->app['env'] = 'production';
ExtensionStaticCacheService::resetPublishScheduleForTesting();
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
Cache::forget('g7:core:ext.static.publish_failure');
// 키 부재 → getExtensionCacheVersion 이 재생성 경로를 탄다.
Cache::forget('g7:core:ext.cache_version');
$this->assertFalse(
ExtensionStaticCacheService::isPublishScheduledForTesting(),
'사전 조건 오염 — 예약 플래그가 이미 서 있다'
);
ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertTrue(
ExtensionStaticCacheService::isPublishScheduledForTesting(),
'재생성 경로가 게시를 예약하지 않았다 — 포인터만 점프하고 산출물이 옛 버전에 남는다'
);
ExtensionStaticCacheService::resetPublishScheduleForTesting();
}
}