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); } }