layoutService = app(LayoutService::class); } /** * meta 병합 테스트 - 자식 우선 */ public function test_merge_meta_with_child_priority(): void { $parent = [ 'meta' => [ 'title' => 'Parent Title', 'description' => 'Parent Description', ], 'data_sources' => [], 'components' => [], ]; $child = [ 'meta' => [ 'title' => 'Child Title', ], 'data_sources' => [], 'components' => [], ]; $result = $this->layoutService->mergeLayouts($parent, $child); $this->assertEquals('Child Title', $result['meta']['title']); $this->assertEquals('Parent Description', $result['meta']['description']); } /** * data_sources 병합 테스트 - 부모와 자식 모두 포함 */ public function test_merge_data_sources_without_duplicates(): void { $parent = [ 'meta' => [], 'data_sources' => [ ['id' => 'users', 'endpoint' => '/api/users'], ], 'components' => [], ]; $child = [ 'meta' => [], 'data_sources' => [ ['id' => 'roles', 'endpoint' => '/api/roles'], ], 'components' => [], ]; $result = $this->layoutService->mergeLayouts($parent, $child); $this->assertCount(2, $result['data_sources']); $this->assertEquals('users', $result['data_sources'][0]['id']); $this->assertEquals('roles', $result['data_sources'][1]['id']); } /** * data_sources ID 중복 예외 테스트 */ public function test_merge_data_sources_throws_exception_on_duplicate_id(): void { $parent = [ 'meta' => [], 'data_sources' => [ ['id' => 'users', 'endpoint' => '/api/users'], ], 'components' => [], ]; $child = [ 'meta' => [], 'data_sources' => [ ['id' => 'users', 'endpoint' => '/api/v2/users'], ], 'components' => [], ]; $this->expectException(\Exception::class); $this->expectExceptionMessage('data_sources ID 중복: users'); $this->layoutService->mergeLayouts($parent, $child); } /** * components slot 교체 테스트 - 단일 컴포넌트 * * 현재 구현: 슬롯 래퍼 컴포넌트의 children에 슬롯 내용이 삽입됨 * 슬롯 래퍼의 id, name, props 등은 유지됨 */ public function test_merge_components_replaces_single_slot(): void { $parent = [ 'meta' => [], 'data_sources' => [], 'components' => [ [ 'component' => 'Container', 'children' => [ ['component' => 'Header', 'slot' => 'header'], ['component' => 'Body', 'slot' => 'content'], ], ], ], ]; $child = [ 'meta' => [], 'data_sources' => [], 'slots' => [ 'header' => [ 'component' => 'CustomHeader', 'props' => ['title' => 'Dashboard'], ], 'content' => [ 'component' => 'DashboardContent', ], ], ]; $result = $this->layoutService->mergeLayouts($parent, $child); $this->assertEquals('Container', $result['components'][0]['component']); // 슬롯 래퍼(Header)의 children에 슬롯 내용(CustomHeader)이 삽입됨 $this->assertEquals('Header', $result['components'][0]['children'][0]['component']); $this->assertEquals('CustomHeader', $result['components'][0]['children'][0]['children'][0]['component']); $this->assertEquals('Dashboard', $result['components'][0]['children'][0]['children'][0]['props']['title']); // 슬롯 래퍼(Body)의 children에 슬롯 내용(DashboardContent)이 삽입됨 $this->assertEquals('Body', $result['components'][0]['children'][1]['component']); $this->assertEquals('DashboardContent', $result['components'][0]['children'][1]['children'][0]['component']); } /** * components slot 교체 테스트 - 복수 컴포넌트 * * 현재 구현: 슬롯 래퍼 컴포넌트의 children에 복수의 슬롯 내용이 삽입됨 */ public function test_merge_components_replaces_multiple_slots(): void { $parent = [ 'meta' => [], 'data_sources' => [], 'components' => [ [ 'component' => 'Layout', 'children' => [ ['component' => 'Slot', 'slot' => 'widgets'], ], ], ], ]; $child = [ 'meta' => [], 'data_sources' => [], 'slots' => [ 'widgets' => [ [ 'component' => 'Widget1', ], [ 'component' => 'Widget2', ], ], ], ]; $result = $this->layoutService->mergeLayouts($parent, $child); $this->assertEquals('Layout', $result['components'][0]['component']); // 슬롯 래퍼(Slot)의 children에 복수의 슬롯 내용이 삽입됨 $this->assertEquals('Slot', $result['components'][0]['children'][0]['component']); $this->assertCount(2, $result['components'][0]['children'][0]['children']); $this->assertEquals('Widget1', $result['components'][0]['children'][0]['children'][0]['component']); $this->assertEquals('Widget2', $result['components'][0]['children'][0]['children'][1]['component']); } /** * 불필요한 필드 제거 테스트 */ public function test_removes_unnecessary_fields(): void { $parent = [ 'meta' => [], 'data_sources' => [], 'components' => [ ['component' => 'Header', 'slot' => 'header'], ], ]; $child = [ 'meta' => [], 'data_sources' => [], 'extends' => 'layouts/_base', 'slots' => [ 'header' => ['component' => 'CustomHeader'], ], ]; $result = $this->layoutService->mergeLayouts($parent, $child); // extends와 slots 필드가 제거되어야 함 $this->assertArrayNotHasKey('extends', $result); $this->assertArrayNotHasKey('slots', $result); // components 내부의 slot 필드도 제거되어야 함 $this->assertArrayNotHasKey('slot', $result['components'][0]); } /** * 복잡한 중첩 구조 병합 테스트 * * 현재 구현: 슬롯 래퍼 컴포넌트의 children에 슬롯 내용이 삽입됨 */ public function test_merge_complex_nested_structure(): void { $parent = [ 'meta' => ['version' => '1.0'], 'data_sources' => [ ['id' => 'config', 'endpoint' => '/api/config'], ], 'components' => [ [ 'component' => 'Layout', 'children' => [ [ 'component' => 'Sidebar', 'children' => [ ['component' => 'Menu', 'slot' => 'menu'], ], ], ['component' => 'Main', 'slot' => 'content'], ], ], ], ]; $child = [ 'meta' => ['title' => 'Dashboard'], 'data_sources' => [ ['id' => 'stats', 'endpoint' => '/api/stats'], ], 'slots' => [ 'menu' => [ 'component' => 'DashboardMenu', ], 'content' => [ 'component' => 'DashboardContent', ], ], ]; $result = $this->layoutService->mergeLayouts($parent, $child); // meta 병합 확인 $this->assertEquals('1.0', $result['meta']['version']); $this->assertEquals('Dashboard', $result['meta']['title']); // data_sources 병합 확인 $this->assertCount(2, $result['data_sources']); // 중첩된 slot 교체 확인 - 슬롯 래퍼(Menu)의 children에 DashboardMenu가 삽입됨 $this->assertEquals('Menu', $result['components'][0]['children'][0]['children'][0]['component']); $this->assertEquals('DashboardMenu', $result['components'][0]['children'][0]['children'][0]['children'][0]['component']); // 슬롯 래퍼(Main)의 children에 DashboardContent가 삽입됨 $this->assertEquals('Main', $result['components'][0]['children'][1]['component']); $this->assertEquals('DashboardContent', $result['components'][0]['children'][1]['children'][0]['component']); // slot 필드 제거 확인 (슬롯 래퍼에서) $this->assertArrayNotHasKey('slot', $result['components'][0]['children'][0]['children'][0]); $this->assertArrayNotHasKey('slot', $result['components'][0]['children'][1]); } /** * slot이 없는 컴포넌트는 그대로 유지되는지 테스트 * * 현재 구현: 슬롯 래퍼의 children에 슬롯 내용이 삽입됨 */ public function test_preserves_components_without_slots(): void { $parent = [ 'meta' => [], 'data_sources' => [], 'components' => [ ['component' => 'Header', 'props' => ['title' => 'App']], ['component' => 'Body', 'slot' => 'content'], ], ]; $child = [ 'meta' => [], 'data_sources' => [], 'slots' => [ 'content' => ['component' => 'CustomBody'], ], ]; $result = $this->layoutService->mergeLayouts($parent, $child); // slot이 없는 Header는 그대로 유지 $this->assertEquals('Header', $result['components'][0]['component']); $this->assertEquals('App', $result['components'][0]['props']['title']); // slot이 있는 Body의 children에 CustomBody가 삽입됨 $this->assertEquals('Body', $result['components'][1]['component']); $this->assertEquals('CustomBody', $result['components'][1]['children'][0]['component']); } /** * getLayout - 정상적인 레이아웃 병합 테스트 * * 현재 구현: 슬롯 래퍼의 children에 슬롯 내용이 삽입됨 */ public function test_get_layout_returns_merged_layout(): void { // 템플릿 생성 - 고유 identifier 사용하여 트랜잭션 락 충돌 방지 $identifier = 'test-template-'.uniqid(); $template = Template::create([ 'identifier' => $identifier, 'vendor' => 'test', 'name' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Active->value, 'description' => ['ko' => '테스트', 'en' => 'Test'], ]); // 부모 레이아웃 생성 TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'base', 'content' => [ 'meta' => ['title' => 'Base Layout'], 'data_sources' => [], 'components' => [ ['component' => 'Container', 'children' => [ ['component' => 'Header', 'slot' => 'header'], ]], ], ], ]); // 자식 레이아웃 생성 TemplateLayout::create([ 'template_id' => $template->id, 'name' => 'dashboard', 'content' => [ 'extends' => 'base', 'meta' => ['description' => 'Dashboard'], 'data_sources' => [], 'slots' => [ 'header' => ['component' => 'DashboardHeader'], ], ], ]); // getLayout 메서드 호출 $result = $this->layoutService->getLayout($identifier, 'dashboard'); // 병합 결과 검증 $this->assertEquals('Base Layout', $result['meta']['title']); $this->assertEquals('Dashboard', $result['meta']['description']); // 슬롯 래퍼(Header)의 children에 DashboardHeader가 삽입됨 $this->assertEquals('Header', $result['components'][0]['children'][0]['component']); $this->assertEquals('DashboardHeader', $result['components'][0]['children'][0]['children'][0]['component']); } /** * getLayout - 템플릿을 찾을 수 없는 경우 예외 발생 */ public function test_get_layout_throws_exception_when_template_not_found(): void { $this->expectException(ModelNotFoundException::class); $this->expectExceptionMessage(__('exceptions.template_not_found', ['identifier' => 'non-existent'])); $this->layoutService->getLayout('non-existent', 'dashboard'); } /** * getLayout - 비활성화된 템플릿 접근 시 예외 발생 */ public function test_get_layout_throws_exception_when_template_not_active(): void { // 비활성화된 템플릿 생성 Template::create([ 'identifier' => 'inactive-template', 'vendor' => 'test', 'name' => ['ko' => '비활성 템플릿', 'en' => 'Inactive Template'], 'version' => '1.0.0', 'type' => 'admin', 'status' => ExtensionStatus::Inactive->value, 'description' => ['ko' => '테스트', 'en' => 'Test'], ]); $this->expectException(ModelNotFoundException::class); $this->expectExceptionMessage(__('exceptions.template_not_active', [ 'identifier' => 'inactive-template', 'status' => ExtensionStatus::Inactive->value, ])); $this->layoutService->getLayout('inactive-template', 'dashboard'); } /** * sanitizeLayoutJson이 script 태그를 제거하는지 테스트 */ public function test_sanitize_removes_script_tags(): void { $layout = [ 'components' => [ [ 'id' => 'c1', 'type' => 'basic', 'name' => 'Component', 'props' => [ 'html' => '
Safe
', ], ], ], ]; $sanitized = $this->layoutService->sanitizeLayoutJson($layout); $this->assertStringNotContainsString('">', ], ], ], ]; $sanitized = $this->layoutService->sanitizeLayoutJson($layout); $this->assertStringNotContainsString('data:', $sanitized['components'][0]['props']['img']); } /** * sanitizeLayoutJson이 HTML entities를 변환하는지 테스트 */ public function test_sanitize_converts_html_entities(): void { $layout = [ 'components' => [ [ 'id' => 'c1', 'type' => 'basic', 'name' => 'Component', 'props' => [ 'text' => '<>&"\'', ], ], ], ]; $sanitized = $this->layoutService->sanitizeLayoutJson($layout); $this->assertStringContainsString('<', $sanitized['components'][0]['props']['text']); $this->assertStringContainsString('>', $sanitized['components'][0]['props']['text']); $this->assertStringContainsString('&', $sanitized['components'][0]['props']['text']); $this->assertStringContainsString('"', $sanitized['components'][0]['props']['text']); } /** * sanitizeLayoutJson이 중첩된 컴포넌트의 props도 sanitize하는지 테스트 */ public function test_sanitize_nested_components(): void { $layout = [ 'components' => [ [ 'id' => 'c1', 'type' => 'composite', 'name' => 'Container', 'children' => [ [ 'id' => 'c1-1', 'type' => 'basic', 'name' => 'Child', 'props' => [ 'html' => '', ], ], ], ], ], ]; $sanitized = $this->layoutService->sanitizeLayoutJson($layout); $this->assertStringNotContainsString('