Files
Gnuboard7/tests/Unit/Extension/TemplateManagerOverrideTest.php
HeuJung d6e1f98a4f fix(core): 확장 오토로드 캐시 공집합 덮어쓰기로 인한 전역 500 + 소스맵 노출 차단 복원
오토로드 캐시(psr4)가 공집합으로 덮어써져 모든 모듈/플러그인 컨트롤러가
해석 불가능해지고 확장 화면 전체가 500 이 됐다. 이 매핑은 "DB 설치 목록 ×
디스크 활성 디렉토리" 교집합이라 DB 쪽만 일시적으로 비어도(테이블 부재·
설치 중간 실패·testing DB) 정상 매핑이 통째로 소실된다. 코어 라우트는 200
인데 확장 라우트만 전부 500 인 비대칭이 이 결함의 진단 단서다.

- 디스크에 확장 디렉토리가 남아 있는데 매핑이 0건이면 기록하지 않고 보존.
 정상적인 0건(신규 설치·마지막 확장 삭제)은 디스크도 함께 비어 통과한다
- generateAutoloadFile 에 testing 실 캐시 경로 쓰기 차단. updateComposerAutoload
 에만 있던 가드를 생성기 자체로 옮겨 어느 경로로 들어와도 막히게 했다
- extension:update-autoload 가 훅 캐시 재생성 전에 런타임 오토로드를 재등록.
 미재등록 시 깨진 매핑으로 훅 캐시가 modules/plugins 0건으로 생성되어
 확장 훅이 조용히 사라지고, 복구에 커맨드 2회 실행이 필요했다

소스맵 서빙은 에서 차단했으나 이번 브랜치에서 화이트리스트에 재추가되어
운영에서 원본 코드 전문(sourcesContent)이 노출될 수 있었다. 이 화이트리스트가
확장 에셋 서빙의 유일한 방어선이다. dev 빌드의 sourceMappingURL 404 를 피하면서
운영을 막도록 local 환경에서만 허용하도록 분기하고, 산출물을 production 으로
재빌드해 docroot 에 남아 있던 소스맵을 제거했다. --production 빌드는 소스맵을
생성하지 않을 뿐 기존 파일을 지우지 않는다.

CoreUpdateCommandSpawnFailureTest 는 in-process fallback 이 실제 업그레이드
스텝을 돌려 코어 역할·권한·메뉴를 커밋하는데 RefreshDatabase 가 없어, 이후
모든 테스트 클래스가 중복 키로 깨졌다(단독 실행 시 미재현).
2026-07-23 13:04:59 +09:00

688 lines
26 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class TemplateManagerOverrideTest extends TestCase
{
use RefreshDatabase;
private TemplateManager $templateManager;
private string $testTemplatePath;
protected function setUp(): void
{
parent::setUp();
// 서비스 컨테이너에서 TemplateManager 인스턴스 가져오기
$this->templateManager = app(TemplateManager::class);
// 테스트용 템플릿 경로
$this->testTemplatePath = base_path('templates/test-admin');
}
protected function tearDown(): void
{
// 테스트 템플릿 디렉토리 정리
if (File::exists($this->testTemplatePath)) {
File::deleteDirectory($this->testTemplatePath);
}
parent::tearDown();
}
/**
* 테스트용 템플릿 구조 생성
*/
private function createTestTemplateStructure(): void
{
// 기본 디렉토리 생성
File::makeDirectory($this->testTemplatePath.'/layouts/overrides/sirsoft-sample', 0755, true);
// template.json 생성
$templateJson = [
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'description' => ['ko' => '테스트용 템플릿', 'en' => 'Test template'],
];
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson, JSON_PRETTY_PRINT));
// 기본 레이아웃 생성
$baseLayout = [
'layout_name' => '_admin_base',
'meta' => ['title' => 'Admin Base'],
'components' => [['component' => 'Container', 'children' => []]],
];
File::put($this->testTemplatePath.'/layouts/_admin_base.json', json_encode($baseLayout, JSON_PRETTY_PRINT));
// 오버라이드 레이아웃 생성
$overrideLayout = [
'layout_name' => 'sirsoft-sample_admin_index',
'extends' => '_admin_base',
'slots' => [
'content' => [
['component' => 'CustomDataTable', 'props' => ['theme' => 'dark']],
],
],
];
File::put(
$this->testTemplatePath.'/layouts/overrides/sirsoft-sample/admin_index.json',
json_encode($overrideLayout, JSON_PRETTY_PRINT)
);
}
/**
* 오버라이드 레이아웃 등록 테스트
*/
public function test_register_layout_overrides(): void
{
$this->createTestTemplateStructure();
// 템플릿 로드
$this->templateManager->loadTemplates();
// 템플릿 레코드 생성
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 리플렉션을 통해 protected 메서드 호출
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 오버라이드 레이아웃이 등록되었는지 확인
$override = TemplateLayout::where('template_id', $template->id)
->where('name', 'sirsoft-sample_admin_index')
->first();
$this->assertNotNull($override);
$this->assertEquals(LayoutSourceType::Template, $override->source_type);
$this->assertEquals('test-admin', $override->source_identifier);
$this->assertEquals('_admin_base', $override->extends);
}
/**
* 오버라이드 레이아웃 content 추출 테스트
*/
public function test_extract_layout_content(): void
{
$layoutData = [
'layout_name' => 'test_layout',
'extends' => '_base',
'slots' => ['content' => [['component' => 'Test']]],
'meta' => ['title' => 'Test'],
'data_sources' => [['id' => 'ds1', 'endpoint' => '/api/test']],
'version' => '1.0',
];
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('extractLayoutContent');
$method->setAccessible(true);
$content = $method->invoke($this->templateManager, $layoutData);
$this->assertArrayHasKey('slots', $content);
$this->assertArrayHasKey('meta', $content);
$this->assertArrayHasKey('data_sources', $content);
$this->assertArrayHasKey('version', $content);
$this->assertArrayNotHasKey('layout_name', $content);
$this->assertArrayNotHasKey('extends', $content);
}
/**
* 오버라이드 레이아웃 이름 생성 테스트
*/
public function test_generate_override_layout_name(): void
{
$basePath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
$filePath = $basePath.'/admin_index.json';
// Windows/Unix 경로 정규화
$normalizedBasePath = str_replace('/', DIRECTORY_SEPARATOR, $basePath);
$normalizedFilePath = str_replace('/', DIRECTORY_SEPARATOR, $filePath);
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('generateLayoutNameFromPath');
$method->setAccessible(true);
$layoutName = $method->invoke($this->templateManager, $normalizedBasePath, $normalizedFilePath, 'sirsoft-sample');
$this->assertEquals('sirsoft-sample_admin_index', $layoutName);
}
/**
* 중첩된 경로의 오버라이드 레이아웃 이름 생성 테스트
*/
public function test_generate_override_layout_name_with_nested_path(): void
{
$basePath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
File::makeDirectory($basePath.'/products', 0755, true);
$filePath = $basePath.'/products/list.json';
// Windows/Unix 경로 정규화
$normalizedBasePath = str_replace('/', DIRECTORY_SEPARATOR, $basePath);
$normalizedFilePath = str_replace('/', DIRECTORY_SEPARATOR, $filePath);
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('generateLayoutNameFromPath');
$method->setAccessible(true);
$layoutName = $method->invoke($this->templateManager, $normalizedBasePath, $normalizedFilePath, 'sirsoft-sample');
$this->assertEquals('sirsoft-sample_products_list', $layoutName);
}
/**
* 오버라이드 파일 스캔 테스트
*/
public function test_scan_override_layout_files(): void
{
// 디렉토리 구조 생성
$overridesPath = $this->testTemplatePath.'/layouts/overrides/sirsoft-sample';
File::makeDirectory($overridesPath.'/nested', 0755, true);
$testLayout = ['slots' => []];
File::put($overridesPath.'/index.json', json_encode($testLayout));
File::put($overridesPath.'/edit.json', json_encode($testLayout));
File::put($overridesPath.'/nested/detail.json', json_encode($testLayout));
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('scanLayoutFilesRecursively');
$method->setAccessible(true);
$files = $method->invoke($this->templateManager, $overridesPath);
$this->assertCount(3, $files);
}
/**
* 오버라이드 캐시 무효화 테스트
*/
public function test_invalidate_override_layout_caches(): void
{
// 템플릿 생성
$template = Template::create([
'identifier' => 'cache-test-template',
'vendor' => 'test',
'name' => ['ko' => '캐시 테스트', 'en' => 'Cache Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 오버라이드 레이아웃 생성
$override = TemplateLayout::create([
'template_id' => $template->id,
'name' => 'module_layout_override',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'cache-test-template',
]);
// 캐시 설정
$cacheKey = "g7:core:template.{$template->id}.layout.{$override->name}";
Cache::put($cacheKey, $override->content, 3600);
$this->assertNotNull(Cache::get($cacheKey));
// 캐시 무효화
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('invalidateOverrideLayoutCaches');
$method->setAccessible(true);
$method->invoke($this->templateManager, $template->id);
// 캐시 삭제 확인
$this->assertNull(Cache::get($cacheKey));
}
/**
* source_type이 template으로 설정되는지 테스트
*/
public function test_override_source_type_is_template(): void
{
$template = Template::create([
'identifier' => 'source-type-test',
'vendor' => 'test',
'name' => ['ko' => '소스타입 테스트', 'en' => 'Source Type Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 오버라이드 레이아웃 생성
$override = TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-sample_admin_index',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'source-type-test',
]);
$this->assertEquals(LayoutSourceType::Template, $override->source_type);
$this->assertNotNull($override->source_identifier);
}
/**
* 여러 모듈의 오버라이드 등록 테스트
*/
public function test_register_overrides_for_multiple_modules(): void
{
// 두 모듈의 오버라이드 디렉토리 생성
$module1Path = $this->testTemplatePath.'/layouts/overrides/sirsoft-module1';
$module2Path = $this->testTemplatePath.'/layouts/overrides/sirsoft-module2';
File::makeDirectory($module1Path, 0755, true);
File::makeDirectory($module2Path, 0755, true);
// template.json 생성
$templateJson = [
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
];
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson));
// 각 모듈의 오버라이드 레이아웃 생성
$layout1 = ['layout_name' => 'sirsoft-module1_admin_index', 'slots' => []];
$layout2 = ['layout_name' => 'sirsoft-module2_admin_index', 'slots' => []];
File::put($module1Path.'/admin_index.json', json_encode($layout1));
File::put($module2Path.'/admin_index.json', json_encode($layout2));
// 템플릿 로드
$this->templateManager->loadTemplates();
// 템플릿 레코드 생성
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '', 'en' => ''],
]);
// 오버라이드 등록
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 두 모듈의 오버라이드가 모두 등록되었는지 확인
$overrides = TemplateLayout::where('template_id', $template->id)
->where('source_type', LayoutSourceType::Template)
->whereNotNull('source_identifier')
->get();
$this->assertCount(2, $overrides);
}
/**
* updateOrCreate로 중복 오버라이드 방지 테스트
*/
public function test_prevent_duplicate_override_registration(): void
{
$template = Template::create([
'identifier' => 'duplicate-test',
'vendor' => 'test',
'name' => ['ko' => '중복테스트', 'en' => 'Duplicate Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$layoutName = 'sirsoft-sample_admin_index';
// 첫 번째 등록
TemplateLayout::updateOrCreate(
[
'template_id' => $template->id,
'name' => $layoutName,
],
[
'content' => ['version' => '1'],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'duplicate-test',
]
);
// 두 번째 등록 (업데이트)
TemplateLayout::updateOrCreate(
[
'template_id' => $template->id,
'name' => $layoutName,
],
[
'content' => ['version' => '2'],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'duplicate-test',
]
);
// 레이아웃이 1개만 존재
$count = TemplateLayout::where('template_id', $template->id)
->where('name', $layoutName)
->count();
$this->assertEquals(1, $count);
// 내용이 업데이트됨
$layout = TemplateLayout::where('template_id', $template->id)
->where('name', $layoutName)
->first();
$this->assertEquals('2', $layout->content['version']);
}
/**
* 오버라이드 디렉토리가 없는 경우 테스트
*/
public function test_handles_missing_overrides_directory(): void
{
// 기본 템플릿 구조만 생성 (overrides 없음)
File::makeDirectory($this->testTemplatePath.'/layouts', 0755, true);
$templateJson = [
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
];
File::put($this->testTemplatePath.'/template.json', json_encode($templateJson));
$this->templateManager->loadTemplates();
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '', 'en' => ''],
]);
// 예외 없이 실행되어야 함
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 오버라이드가 등록되지 않음
$overrides = TemplateLayout::where('template_id', $template->id)
->where('source_type', LayoutSourceType::Template)
->whereNotNull('source_identifier')
->count();
$this->assertEquals(0, $overrides);
}
/**
* incrementExtensionCacheVersion 메서드가 캐시 버전을 증가시키는지 테스트
*/
public function test_increment_extension_cache_version(): void
{
// 초기 캐시 버전 설정
Cache::put('g7:core:ext.cache_version', 1000);
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('incrementExtensionCacheVersion');
$method->setAccessible(true);
$beforeTime = time();
$method->invoke($this->templateManager);
$afterTime = time();
$newVersion = Cache::get('g7:core:ext.cache_version');
$this->assertGreaterThanOrEqual($beforeTime, $newVersion);
$this->assertLessThanOrEqual($afterTime, $newVersion);
}
/**
* getExtensionCacheVersion 정적 메서드 테스트
*/
public function test_get_extension_cache_version(): void
{
$expectedVersion = 1735000000;
Cache::put('g7:core:ext.cache_version', $expectedVersion);
// ClearsTemplateCaches trait의 정적 메서드 호출
$version = ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertEquals($expectedVersion, $version);
}
/**
* 캐시 버전 키가 없으면 유효한 새 버전을 생성·저장하는지 테스트
*
* 0 을 반환하면 버전 기반 캐시 무효화가 통째로 붕괴한다(모든 URL 이 `?v=0`
* 으로 고정되어 갱신이 전파되지 않음). 키 부재는 캐시 플러시·TTL 만료로
* 언제든 발생하므로, 그 자리에서 새 버전을 만들어 저장한다.
*/
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();
$this->assertGreaterThan(0, $version, '키 부재 시 0 을 반환하면 캐시 무효화가 붕괴합니다.');
$this->assertGreaterThanOrEqual($beforeTime, $version);
$this->assertLessThanOrEqual($afterTime, $version);
$this->assertEquals(
$version,
Cache::get('g7:core:ext.cache_version'),
'생성한 버전은 캐시에 저장되어 다음 조회에서 재사용되어야 합니다.'
);
}
/**
* layout_name 변경 시 이전 이름의 고아 레코드가 삭제되는지 테스트
*/
public function test_cleanup_orphan_override_when_layout_name_changes(): void
{
$this->createTestTemplateStructure();
$this->templateManager->loadTemplates();
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 이전 이름으로 오버라이드 레이아웃이 DB에 이미 등록된 상태 시뮬레이션
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-sample_old_name',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin',
]);
// registerLayoutOverrides 실행 (파일에는 sirsoft-sample_admin_index만 존재)
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 새 이름의 오버라이드가 등록되어야 함
$newOverride = TemplateLayout::where('template_id', $template->id)
->where('name', 'sirsoft-sample_admin_index')
->first();
$this->assertNotNull($newOverride);
// 이전 이름의 고아 레코드가 삭제되어야 함
$oldOverride = TemplateLayout::where('template_id', $template->id)
->where('name', 'sirsoft-sample_old_name')
->first();
$this->assertNull($oldOverride);
}
/**
* 다른 템플릿의 오버라이드는 정리하지 않는지 테스트
*/
public function test_cleanup_does_not_affect_other_template_overrides(): void
{
$this->createTestTemplateStructure();
$this->templateManager->loadTemplates();
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 다른 source_identifier를 가진 오버라이드 (다른 템플릿에서 등록)
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-sample_other_layout',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'other-template',
]);
// registerLayoutOverrides 실행
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 다른 템플릿의 오버라이드는 삭제되지 않아야 함
$otherOverride = TemplateLayout::where('template_id', $template->id)
->where('name', 'sirsoft-sample_other_layout')
->where('source_identifier', 'other-template')
->first();
$this->assertNotNull($otherOverride);
}
/**
* overrides 디렉토리 삭제 시 기존 override가 모두 정리되는지 테스트
*/
public function test_cleanup_all_overrides_when_directory_removed(): void
{
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 기존 오버라이드 레이아웃이 DB에 존재
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-sample_admin_index',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin',
]);
TemplateLayout::create([
'template_id' => $template->id,
'name' => 'sirsoft-sample_admin_edit',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin',
]);
// overrides 디렉토리가 없는 상태에서 registerLayoutOverrides 실행
// (templates/test-admin/layouts/overrides가 존재하지 않음)
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('registerLayoutOverrides');
$method->setAccessible(true);
$method->invoke($this->templateManager, 'test-admin', $template->id);
// 모든 오버라이드가 삭제되어야 함
$overrides = TemplateLayout::where('template_id', $template->id)
->where('source_type', LayoutSourceType::Template)
->where('source_identifier', 'test-admin')
->count();
$this->assertEquals(0, $overrides);
}
/**
* 고아 레코드 정리 시 관련 캐시도 삭제되는지 테스트
*/
public function test_cleanup_orphan_overrides_also_clears_cache(): void
{
$template = Template::create([
'identifier' => 'test-admin',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$orphanName = 'sirsoft-sample_orphan_layout';
// 고아 레이아웃 DB에 생성
TemplateLayout::create([
'template_id' => $template->id,
'name' => $orphanName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin',
]);
// 캐시 설정
$cacheKey = "g7:core:template.{$template->id}.layout.{$orphanName}";
Cache::put($cacheKey, ['cached' => true], 3600);
$this->assertNotNull(Cache::get($cacheKey));
// cleanupOrphanOverrideLayouts 실행 (등록된 이름 목록은 비어있음)
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('cleanupOrphanOverrideLayouts');
$method->setAccessible(true);
$method->invoke($this->templateManager, $template->id, 'test-admin', []);
// 캐시도 삭제되어야 함
$this->assertNull(Cache::get($cacheKey));
// DB 레코드도 삭제되어야 함
$orphan = TemplateLayout::where('template_id', $template->id)
->where('name', $orphanName)
->first();
$this->assertNull($orphan);
}
}