forgetInstance(ModuleManager::class); app()->forgetInstance(LayoutService::class); app()->forgetInstance(LayoutResolverService::class); app()->forgetInstance(\App\Services\TemplateService::class); // DI 컨테이너를 통해 새로운 인스턴스 획득 $this->moduleManager = app(ModuleManager::class); $this->layoutService = app(LayoutService::class); $this->resolverService = app(LayoutResolverService::class); // 테스트용 활성화된 admin 템플릿 생성 $this->adminTemplate = Template::create([ 'identifier' => 'sirsoft-admin_basic', 'vendor' => 'sirsoft', 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); } /** * 모든 레이아웃 관련 캐시를 무효화합니다. * * 캐시 키가 소스 해시를 포함하므로 오버라이드 추가/삭제 시 키가 변경됩니다. * 테스트 환경에서는 Cache::flush()로 모든 캐시를 초기화하고, * 서비스 인스턴스도 재생성하여 내부 상태를 초기화합니다. */ private function clearAllLayoutCaches(string $templateIdentifier, int $templateId, string $layoutName): void { // 1. 모든 캐시 초기화 Cache::flush(); // 2. 모든 관련 서비스 인스턴스 초기화 (다음 요청에서 새로 생성되도록) // 이렇게 하면 컨트롤러에 주입되는 서비스도 새로운 인스턴스가 됨 app()->forgetInstance(LayoutService::class); app()->forgetInstance(LayoutResolverService::class); app()->forgetInstance(\App\Services\TemplateService::class); // 3. 테스트 클래스의 참조도 새로운 인스턴스로 갱신 $this->layoutService = app(LayoutService::class); $this->resolverService = app(LayoutResolverService::class); } /** * 테스트 케이스 1: 모듈 설치 → 레이아웃 등록 → API 조회 * * 모듈이 설치되고 활성화되면 해당 모듈의 레이아웃이 * DB에 등록되고 API를 통해 조회할 수 있어야 합니다. */ public function test_module_layout_registration_and_api_query(): void { $moduleIdentifier = 'sirsoft-sample'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 모듈 레이아웃을 DB에 직접 등록 (모듈 활성화 시뮬레이션) $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => [ 'title' => 'Sample Module Index', 'description' => 'Sample module index page', ], 'data_sources' => [], 'components' => [ [ 'component' => 'Container', 'children' => [ [ 'component' => 'DataTable', 'props' => [ 'columns' => [ ['key' => 'id', 'label' => 'ID'], ['key' => 'name', 'label' => 'Name'], ], ], ], ], ], ], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 2. 레이아웃이 DB에 등록되었는지 확인 $this->assertDatabaseHas('template_layouts', [ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'source_type' => LayoutSourceType::Module->value, 'source_identifier' => $moduleIdentifier, ]); // 3. API를 통한 레이아웃 조회 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); // 4. 성공 응답 확인 $response->assertStatus(200) ->assertJson([ 'success' => true, ]) ->assertJsonPath('data.meta.title', 'Sample Module Index'); // 5. LayoutResolverService를 통한 해석 확인 $resolved = $this->resolverService->resolve($layoutName, $this->adminTemplate->id); $this->assertNotNull($resolved); $this->assertEquals($moduleLayout->id, $resolved->id); $this->assertEquals(LayoutSourceType::Module, $resolved->source_type); } /** * 테스트 케이스 2: 템플릿 오버라이드 → 우선순위 적용 * * 템플릿이 모듈 레이아웃을 오버라이드하면 * 오버라이드 레이아웃이 우선적으로 사용되어야 합니다. */ public function test_template_override_priority_application(): void { $moduleIdentifier = 'sirsoft-sample'; $layoutName = "{$moduleIdentifier}_admin_products_index"; // 1. 모듈 기본 레이아웃 등록 $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => [ 'title' => 'Module Default Layout', 'theme' => 'default', ], 'data_sources' => [], 'components' => [ ['component' => 'ModuleDefaultComponent'], ], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 2. 템플릿 오버라이드 레이아웃 등록 $overrideLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => [ 'title' => 'Template Override Layout', 'theme' => 'custom-dark', ], 'data_sources' => [], 'components' => [ ['component' => 'CustomOverrideComponent', 'props' => ['theme' => 'dark']], ], ], 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); // 3. 두 레이아웃이 모두 DB에 존재하는지 확인 $layoutCount = TemplateLayout::where('template_id', $this->adminTemplate->id) ->where('name', $layoutName) ->count(); $this->assertEquals(2, $layoutCount); // 4. LayoutResolverService를 통해 해석 - 오버라이드가 우선 $resolved = $this->resolverService->resolve($layoutName, $this->adminTemplate->id); $this->assertNotNull($resolved); $this->assertEquals($overrideLayout->id, $resolved->id); $this->assertEquals(LayoutSourceType::Template, $resolved->source_type); // 5. API 조회 시 오버라이드 레이아웃이 반환되는지 확인 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200) ->assertJsonPath('data.meta.title', 'Template Override Layout') ->assertJsonPath('data.meta.theme', 'custom-dark'); // 6. isOverridden 메서드 확인 $this->assertTrue($this->resolverService->isOverridden($layoutName, $this->adminTemplate->id)); // 사용되지 않는 변수 경고 방지 $this->assertNotNull($moduleLayout); } /** * 테스트 케이스 3: 모듈 비활성화 → 레이아웃 제거 * * 모듈이 비활성화되면 해당 모듈의 레이아웃이 * soft delete되어 API 조회에서 제외되어야 합니다. */ public function test_module_deactivation_layout_removal(): void { $moduleIdentifier = 'sirsoft-deactivate-test'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 모듈 레이아웃 등록 $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => ['title' => 'Deactivate Test Layout'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 2. 레이아웃이 활성 상태인지 확인 $this->assertNotNull(TemplateLayout::find($moduleLayout->id)); // 3. API 조회 성공 확인 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200); // 4. 모듈 비활성화 시뮬레이션 - soft delete 및 캐시 무효화 $moduleLayout->delete(); $this->clearAllLayoutCaches($this->adminTemplate->identifier, $this->adminTemplate->id, $layoutName); // 5. 일반 조회에서는 나타나지 않음 $this->assertNull(TemplateLayout::find($moduleLayout->id)); // 6. withTrashed로는 조회 가능 $this->assertNotNull(TemplateLayout::withTrashed()->find($moduleLayout->id)); // 7. API 조회 실패 확인 (레이아웃을 찾을 수 없음) $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(404); // 8. LayoutResolverService도 null 반환 $resolved = $this->resolverService->resolve($layoutName, $this->adminTemplate->id); $this->assertNull($resolved); } /** * 테스트: 모듈 재활성화 시 레이아웃 복원 * * soft delete된 모듈 레이아웃이 모듈 재활성화 시 복원되어야 합니다. */ public function test_module_reactivation_restores_layouts(): void { $moduleIdentifier = 'sirsoft-reactivate-test'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 모듈 레이아웃 생성 및 soft delete $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'meta' => ['title' => 'Reactivate Test'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); $moduleLayout->delete(); // 2. soft deleted 상태 확인 $this->assertSoftDeleted('template_layouts', ['id' => $moduleLayout->id]); // 3. ModuleManager의 restoreModuleLayouts 호출 (protected 메서드) $reflection = new \ReflectionClass($this->moduleManager); $method = $reflection->getMethod('restoreModuleLayouts'); $method->setAccessible(true); $method->invoke($this->moduleManager, $moduleIdentifier); // 4. 레이아웃 복원 확인 $restoredLayout = TemplateLayout::find($moduleLayout->id); $this->assertNotNull($restoredLayout); $this->assertNull($restoredLayout->deleted_at); // 5. API 조회 성공 확인 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200); } /** * 테스트: 레이아웃 상속과 오버라이드 통합 동작 * * 모듈 레이아웃이 템플릿의 베이스 레이아웃을 extends하고 * 템플릿이 해당 모듈 레이아웃을 오버라이드하는 전체 흐름을 테스트합니다. * * 이 테스트는 오버라이드가 이미 존재할 때 API가 오버라이드를 반환하는지 검증합니다. * 캐시 상태 변경 테스트는 test_cache_invalidation_on_layout_change에서 수행합니다. */ public function test_layout_inheritance_with_override(): void { // 고유한 식별자 사용 (다른 테스트와의 캐시 충돌 방지) // 주의: moduleIdentifier는 vendor-module 형식이어야 isModuleLayoutName() 정규식에 매칭됨 // 정규식: ^[a-z0-9]+-[a-z0-9]+_ (하이픈 하나만 허용) $uniqueId = uniqid(); $moduleIdentifier = "sirsoft-inherit{$uniqueId}"; $layoutName = "{$moduleIdentifier}_admin_detail"; // 1. 템플릿 베이스 레이아웃 생성 (고유한 이름 사용) $baseLayoutName = "_admin_base_{$uniqueId}"; $baseLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $baseLayoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $baseLayoutName, 'meta' => [ 'title' => 'Admin Base', ], 'data_sources' => [], 'components' => [ [ 'component' => 'Container', 'props' => ['class' => 'admin-container'], 'children' => [ ['component' => 'Header', 'slot' => 'header'], ['component' => 'MainContent', 'slot' => 'content'], ['component' => 'Footer', 'slot' => 'footer'], ], ], ], ], 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); // 2. 모듈 레이아웃 (베이스 상속) $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'extends' => $baseLayoutName, 'meta' => [ 'title' => 'Module Detail Page', ], 'slots' => [ 'content' => [ ['component' => 'DetailView', 'props' => ['style' => 'default']], ], ], ], 'extends' => $baseLayoutName, 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 3. 템플릿 오버라이드도 함께 생성 (캐시 생성 전에 모든 데이터 준비) $overrideLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'extends' => $baseLayoutName, 'meta' => [ 'title' => 'Custom Detail Page', 'theme' => 'premium', ], 'slots' => [ 'content' => [ ['component' => 'CustomDetailView', 'props' => ['style' => 'premium']], ], ], ], 'extends' => $baseLayoutName, 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); // 4. LayoutResolverService로 먼저 직접 확인 (캐시 없이) // 새로운 인스턴스 생성하여 캐시 상태 배제 $freshResolverService = app(LayoutResolverService::class); $resolved = $freshResolverService->resolve($layoutName, $this->adminTemplate->id); $this->assertNotNull($resolved, 'LayoutResolverService should resolve the layout'); $this->assertEquals($overrideLayout->id, $resolved->id, 'Resolver should return override layout'); $this->assertEquals(LayoutSourceType::Template, $resolved->source_type, 'Source type should be Template'); // 5. API 조회 - 캐시 초기화 후 새 요청 Cache::flush(); app()->forgetInstance(LayoutService::class); app()->forgetInstance(LayoutResolverService::class); app()->forgetInstance(\App\Services\TemplateService::class); $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200) ->assertJsonPath('data.meta.title', 'Custom Detail Page') ->assertJsonPath('data.meta.theme', 'premium'); // 병합 후 extends와 slots가 제거되었는지 확인 $this->assertArrayNotHasKey('extends', $response->json('data')); $this->assertArrayNotHasKey('slots', $response->json('data')); // 사용되지 않는 변수 경고 방지 $this->assertNotNull($baseLayout); $this->assertNotNull($moduleLayout); } /** * 테스트: 캐시 무효화 통합 테스트 * * 모듈 레이아웃 변경 시 캐시가 올바르게 무효화되는지 테스트합니다. */ public function test_cache_invalidation_on_layout_change(): void { $moduleIdentifier = 'sirsoft-cache-test'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 모듈 레이아웃 생성 $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => ['title' => 'Original Title'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 2. 첫 번째 API 조회 (캐시 생성) $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200) ->assertJsonPath('data.meta.title', 'Original Title'); // 3. 레이아웃 content 직접 업데이트 $moduleLayout->update([ 'content' => [ 'version' => '1.0.0', 'layout_name' => $layoutName, 'meta' => ['title' => 'Updated Title'], 'data_sources' => [], 'components' => [], ], ]); // 4. 캐시 무효화 $this->clearAllLayoutCaches($this->adminTemplate->identifier, $this->adminTemplate->id, $layoutName); // 5. 다시 API 조회 - 업데이트된 내용 확인 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200) ->assertJsonPath('data.meta.title', 'Updated Title'); } /** * 테스트: 다중 admin 템플릿에 모듈 레이아웃 등록 * * 모듈 레이아웃이 여러 admin 템플릿에 등록되고 * 각 템플릿에서 독립적으로 동작하는지 테스트합니다. */ public function test_module_layout_in_multiple_admin_templates(): void { $moduleIdentifier = 'sirsoft-multi-template'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 두 번째 admin 템플릿 생성 $secondTemplate = Template::create([ 'identifier' => 'sirsoft-admin_premium', 'vendor' => 'sirsoft', 'name' => ['ko' => '프리미엄 관리자 템플릿', 'en' => 'Premium Admin Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '프리미엄 템플릿', 'en' => 'Premium Template'], ]); // 2. 첫 번째 템플릿에 모듈 레이아웃 등록 TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'meta' => ['title' => 'Basic Template Layout'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 3. 두 번째 템플릿에 모듈 레이아웃 등록 TemplateLayout::create([ 'template_id' => $secondTemplate->id, 'name' => $layoutName, 'content' => [ 'meta' => ['title' => 'Premium Template Layout'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 4. 각 템플릿에서 API 조회 $response1 = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response1->assertStatus(200) ->assertJsonPath('data.meta.title', 'Basic Template Layout'); $response2 = $this->getJson("/api/layouts/{$secondTemplate->identifier}/{$layoutName}.json"); $response2->assertStatus(200) ->assertJsonPath('data.meta.title', 'Premium Template Layout'); // 5. LayoutResolverService가 각 템플릿을 독립적으로 처리하는지 확인 $resolved1 = $this->resolverService->resolve($layoutName, $this->adminTemplate->id); $resolved2 = $this->resolverService->resolve($layoutName, $secondTemplate->id); $this->assertNotEquals($resolved1->id, $resolved2->id); } /** * 테스트: 오버라이드 레이아웃 목록 조회 * * 특정 템플릿에서 오버라이드된 레이아웃 목록을 조회합니다. */ public function test_get_overridden_layouts_list(): void { $moduleIdentifier = 'sirsoft-override-list'; // 1. 여러 모듈 레이아웃 생성 for ($i = 1; $i <= 3; $i++) { $layoutName = "{$moduleIdentifier}_layout_{$i}"; TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => ['meta' => [], 'data_sources' => [], 'components' => []], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); } // 2. 일부만 오버라이드 TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => "{$moduleIdentifier}_layout_1", 'content' => ['meta' => ['theme' => 'custom'], 'data_sources' => [], 'components' => []], 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => "{$moduleIdentifier}_layout_3", 'content' => ['meta' => ['theme' => 'premium'], 'data_sources' => [], 'components' => []], 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); // 3. 오버라이드된 레이아웃 목록 조회 $overriddenLayouts = $this->resolverService->getOverriddenLayouts($this->adminTemplate->id); $this->assertCount(2, $overriddenLayouts); $overriddenNames = $overriddenLayouts->pluck('name')->toArray(); $this->assertContains("{$moduleIdentifier}_layout_1", $overriddenNames); $this->assertContains("{$moduleIdentifier}_layout_3", $overriddenNames); // layout_2는 오버라이드되지 않음 $this->assertFalse($this->resolverService->isOverridden( "{$moduleIdentifier}_layout_2", $this->adminTemplate->id )); } /** * 테스트: 오버라이드 없이 모듈 기본 레이아웃만 있을 때 정상 동작 * * 템플릿 오버라이드가 없으면 모듈 기본 레이아웃이 사용되어야 합니다. */ public function test_fallback_to_module_layout_when_override_removed(): void { $moduleIdentifier = 'sirsoft-fallback-test'; $layoutName = "{$moduleIdentifier}_admin_index"; // 1. 모듈 기본 레이아웃만 생성 (오버라이드 없음) $moduleLayout = TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => $layoutName, 'content' => [ 'meta' => ['title' => 'Module Default'], 'data_sources' => [], 'components' => [], ], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); // 2. 모듈 기본 레이아웃 조회 확인 $response = $this->getJson("/api/layouts/{$this->adminTemplate->identifier}/{$layoutName}.json"); $response->assertStatus(200) ->assertJsonPath('data.meta.title', 'Module Default'); // 3. 오버라이드되지 않았는지 확인 $this->assertFalse($this->resolverService->isOverridden($layoutName, $this->adminTemplate->id)); // 4. LayoutResolverService로 직접 확인 $resolved = $this->resolverService->resolve($layoutName, $this->adminTemplate->id); $this->assertEquals($moduleLayout->id, $resolved->id); $this->assertEquals(LayoutSourceType::Module, $resolved->source_type); } /** * 테스트: 모듈 레이아웃 소스 타입 필터링 * * source_type에 따라 레이아웃을 필터링하여 조회할 수 있는지 테스트합니다. */ public function test_filter_layouts_by_source_type(): void { $moduleIdentifier = 'sirsoft-filter-test'; // 1. 모듈 레이아웃 3개 생성 for ($i = 1; $i <= 3; $i++) { TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => "{$moduleIdentifier}_module_{$i}", 'content' => ['meta' => [], 'data_sources' => [], 'components' => []], 'source_type' => LayoutSourceType::Module, 'source_identifier' => $moduleIdentifier, ]); } // 2. 템플릿 레이아웃 2개 생성 for ($i = 1; $i <= 2; $i++) { TemplateLayout::create([ 'template_id' => $this->adminTemplate->id, 'name' => "template_layout_{$i}", 'content' => ['meta' => [], 'data_sources' => [], 'components' => []], 'source_type' => LayoutSourceType::Template, 'source_identifier' => $this->adminTemplate->identifier, ]); } // 3. 모듈 레이아웃만 필터링 $moduleLayouts = TemplateLayout::where('template_id', $this->adminTemplate->id) ->where('source_type', LayoutSourceType::Module) ->get(); $this->assertCount(3, $moduleLayouts); // 4. 템플릿 레이아웃만 필터링 $templateLayouts = TemplateLayout::where('template_id', $this->adminTemplate->id) ->where('source_type', LayoutSourceType::Template) ->get(); $this->assertCount(2, $templateLayouts); // 5. 특정 모듈의 레이아웃만 필터링 $specificModuleLayouts = TemplateLayout::where('template_id', $this->adminTemplate->id) ->where('source_type', LayoutSourceType::Module) ->where('source_identifier', $moduleIdentifier) ->get(); $this->assertCount(3, $specificModuleLayouts); } }