관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자 리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다. 경계값 SSoT 도입: - config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. - 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다. 적용되지 않던 정책을 실제로 적용: - 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함) - 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로 - 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책, 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세 파생 결함: - 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고, 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다. - 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로 두고, 빈 목록 자체를 캐시하지 않는다. - 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다. - 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여). 재발 방지: - audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프) - 테스트 실행 범위 강제 훅(check-test-scope) - 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착) 공개 이슈: gnuboard/g7
1227 lines
44 KiB
PHP
1227 lines
44 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Services;
|
|
|
|
use App\Contracts\Extension\ModuleInterface;
|
|
use App\Contracts\Extension\PluginInterface;
|
|
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
|
|
use App\Enums\LayoutExtensionType;
|
|
use App\Enums\LayoutSourceType;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Models\LayoutExtension;
|
|
use App\Models\Template;
|
|
use App\Services\LayoutExtensionService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* LayoutExtension 통합 테스트
|
|
*
|
|
* 모듈/플러그인 설치 및 제거 시 Extension 등록/삭제 전체 흐름을 테스트합니다.
|
|
*/
|
|
class LayoutExtensionIntegrationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 같은 스위트의 레이아웃/GDPR 미들웨어 의존 테스트와 migrate:fresh 정합성을
|
|
* 맞추기 위해 GDPR 플러그인 마이그레이션을 일관 선언한다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
protected array $requiredExtensions = [
|
|
'plugins/sirsoft-gdpr',
|
|
];
|
|
|
|
private LayoutExtensionService $service;
|
|
|
|
private LayoutExtensionRepositoryInterface $repository;
|
|
|
|
private Template $template;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->service = $this->app->make(LayoutExtensionService::class);
|
|
$this->repository = $this->app->make(LayoutExtensionRepositoryInterface::class);
|
|
$this->template = Template::factory()->create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'status' => 'active',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 모듈 Extension 등록 및 레이아웃 적용 전체 흐름 테스트
|
|
*/
|
|
public function test_module_extension_registration_and_layout_application_flow(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// 1. 모듈이 Extension Point 등록
|
|
$extensionPointData = [
|
|
'extension_point' => 'sidebar-top',
|
|
'components' => [
|
|
[
|
|
'id' => 'ecommerce-cart-widget',
|
|
'type' => 'composite',
|
|
'name' => 'CartWidget',
|
|
'props' => ['position' => 'sidebar'],
|
|
],
|
|
],
|
|
'priority' => 20,
|
|
];
|
|
|
|
$this->service->registerExtension(
|
|
$extensionPointData,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertDatabaseHas('template_layout_extensions', [
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint->value,
|
|
'target_name' => 'sidebar-top',
|
|
'source_identifier' => 'sirsoft-ecommerce',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// 2. 레이아웃에 Extension Point 적용
|
|
$layout = [
|
|
'layout_name' => 'admin-dashboard',
|
|
'components' => [
|
|
[
|
|
'id' => 'sidebar',
|
|
'type' => 'layout',
|
|
'name' => 'Sidebar',
|
|
'children' => [
|
|
[
|
|
'id' => 'sidebar-top',
|
|
'type' => 'extension_point',
|
|
'name' => 'sidebar-top',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 3. Extension이 적용되었는지 확인
|
|
$sidebarTopChildren = $result['components'][0]['children'][0]['children'] ?? [];
|
|
$this->assertCount(1, $sidebarTopChildren);
|
|
$this->assertEquals('ecommerce-cart-widget', $sidebarTopChildren[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* 모듈 비활성화 시 Extension soft delete 및 재활성화 시 복원 테스트
|
|
*/
|
|
public function test_module_deactivation_and_reactivation_preserves_extensions(): void
|
|
{
|
|
// 1. 모듈 Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'header-actions',
|
|
'components' => [['id' => 'cart-icon', 'type' => 'basic', 'name' => 'Icon']],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertEquals(1, LayoutExtension::count());
|
|
|
|
// 2. 모듈 비활성화 (soft delete)
|
|
$deleted = $this->service->unregisterBySource(LayoutSourceType::Module, 'sirsoft-ecommerce');
|
|
$this->assertEquals(1, $deleted);
|
|
$this->assertEquals(0, LayoutExtension::count());
|
|
$this->assertEquals(1, LayoutExtension::onlyTrashed()->count());
|
|
|
|
// 3. 모듈 재활성화 (복원)
|
|
$restored = $this->service->restoreBySource(LayoutSourceType::Module, 'sirsoft-ecommerce');
|
|
$this->assertEquals(1, $restored);
|
|
$this->assertEquals(1, LayoutExtension::count());
|
|
}
|
|
|
|
/**
|
|
* Overlay를 통한 기존 컴포넌트에 주입 테스트
|
|
*/
|
|
public function test_overlay_injection_into_existing_component(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// 1. Overlay 등록 (기존 컴포넌트에 자식 추가)
|
|
$overlayData = [
|
|
'target_layout' => 'admin/settings',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'settings-form',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
[
|
|
'id' => 'ecommerce-settings-section',
|
|
'type' => 'composite',
|
|
'name' => 'SettingsSection',
|
|
'props' => ['title' => 'Ecommerce Settings'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$this->service->registerExtension(
|
|
$overlayData,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 레이아웃에 Overlay 적용
|
|
$layout = [
|
|
'layout_name' => 'admin/settings',
|
|
'components' => [
|
|
[
|
|
'id' => 'settings-form',
|
|
'type' => 'composite',
|
|
'name' => 'Form',
|
|
'children' => [
|
|
['id' => 'general-settings', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 3. Overlay가 적용되었는지 확인
|
|
$formChildren = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(2, $formChildren);
|
|
$this->assertEquals('ecommerce-settings-section', $formChildren[1]['id']);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 오버라이드가 모듈 Extension보다 우선순위가 높은지 테스트
|
|
*/
|
|
public function test_template_override_has_higher_priority_than_module(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// 1. 모듈 Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'sidebar-top',
|
|
'components' => [
|
|
[
|
|
'id' => 'module-widget',
|
|
'type' => 'basic',
|
|
'name' => 'ModuleWidget',
|
|
],
|
|
],
|
|
'priority' => 50,
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 템플릿 오버라이드 등록 (모듈 Extension을 대체)
|
|
$this->service->registerTemplateOverride(
|
|
[
|
|
'extension_point' => 'sidebar-top',
|
|
'components' => [
|
|
[
|
|
'id' => 'template-widget',
|
|
'type' => 'basic',
|
|
'name' => 'TemplateWidget',
|
|
],
|
|
],
|
|
'priority' => 10,
|
|
],
|
|
'sirsoft-admin_basic',
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 3. 레이아웃에 Extension 적용
|
|
$layout = [
|
|
'layout_name' => 'dashboard',
|
|
'components' => [
|
|
[
|
|
'id' => 'sidebar-top',
|
|
'type' => 'extension_point',
|
|
'name' => 'sidebar-top',
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 4. 템플릿 오버라이드만 적용되고 모듈 Extension은 제외됨
|
|
$extensionPointChildren = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(1, $extensionPointChildren);
|
|
$this->assertEquals('template-widget', $extensionPointChildren[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* 다중 모듈의 Extension이 우선순위대로 정렬되는지 테스트
|
|
*/
|
|
public function test_multiple_module_extensions_sorted_by_priority(): void
|
|
{
|
|
$this->mockActiveExtensions(
|
|
['sirsoft-analytics', 'sirsoft-ecommerce'],
|
|
['sirsoft-social']
|
|
);
|
|
|
|
// 1. 여러 모듈의 Extension 등록 (다른 우선순위)
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'footer-widgets',
|
|
'components' => [['id' => 'analytics-widget', 'type' => 'basic', 'name' => 'AnalyticsWidget']],
|
|
'priority' => 30,
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-analytics',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'footer-widgets',
|
|
'components' => [['id' => 'ecommerce-widget', 'type' => 'basic', 'name' => 'EcommerceWidget']],
|
|
'priority' => 10, // 더 높은 우선순위 (낮은 숫자)
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'footer-widgets',
|
|
'components' => [['id' => 'social-widget', 'type' => 'basic', 'name' => 'SocialWidget']],
|
|
'priority' => 20,
|
|
],
|
|
LayoutSourceType::Plugin,
|
|
'sirsoft-social',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 레이아웃에 Extension 적용
|
|
$layout = [
|
|
'layout_name' => 'footer',
|
|
'components' => [
|
|
[
|
|
'id' => 'footer-widgets',
|
|
'type' => 'extension_point',
|
|
'name' => 'footer-widgets',
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 3. 우선순위 순으로 정렬 확인 (10, 20, 30)
|
|
$widgets = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(3, $widgets);
|
|
$this->assertEquals('ecommerce-widget', $widgets[0]['id']); // priority 10
|
|
$this->assertEquals('social-widget', $widgets[1]['id']); // priority 20
|
|
$this->assertEquals('analytics-widget', $widgets[2]['id']); // priority 30
|
|
}
|
|
|
|
/**
|
|
* data_sources 병합 테스트
|
|
*/
|
|
public function test_data_sources_merged_from_extensions(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// 1. data_sources가 포함된 Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'dashboard-widgets',
|
|
'components' => [['id' => 'sales-chart', 'type' => 'composite', 'name' => 'SalesChart']],
|
|
'data_sources' => [
|
|
'salesData' => [
|
|
'endpoint' => '/api/ecommerce/sales',
|
|
'method' => 'GET',
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 레이아웃에 Extension 적용
|
|
$layout = [
|
|
'layout_name' => 'dashboard',
|
|
'data_sources' => [
|
|
'users' => ['endpoint' => '/api/users', 'method' => 'GET'],
|
|
],
|
|
'components' => [
|
|
[
|
|
'id' => 'dashboard-widgets',
|
|
'type' => 'extension_point',
|
|
'name' => 'dashboard-widgets',
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 3. data_sources가 병합되었는지 확인
|
|
$this->assertArrayHasKey('data_sources', $result);
|
|
$this->assertArrayHasKey('users', $result['data_sources']);
|
|
$this->assertArrayHasKey('salesData', $result['data_sources']);
|
|
}
|
|
|
|
/**
|
|
* 비활성 Extension이 레이아웃에 적용되지 않는지 테스트
|
|
*/
|
|
public function test_inactive_extensions_not_applied_to_layout(): void
|
|
{
|
|
// sirsoft-active 만 활성 — sirsoft-inactive 는 activeModules 에 없음
|
|
$this->mockActiveExtensions(['sirsoft-active'], []);
|
|
|
|
// 1. 활성 Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'sidebar-top',
|
|
'components' => [['id' => 'active-widget', 'type' => 'basic', 'name' => 'ActiveWidget']],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-active',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 비활성 Extension 생성
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'sidebar-top',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-inactive',
|
|
'content' => ['components' => [['id' => 'inactive-widget', 'type' => 'basic', 'name' => 'InactiveWidget']]],
|
|
'is_active' => false,
|
|
]);
|
|
|
|
// 3. 레이아웃에 Extension 적용
|
|
$layout = [
|
|
'layout_name' => 'sidebar',
|
|
'components' => [
|
|
[
|
|
'id' => 'sidebar-top',
|
|
'type' => 'extension_point',
|
|
'name' => 'sidebar-top',
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 4. 활성 Extension만 적용됨
|
|
$widgets = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(1, $widgets);
|
|
$this->assertEquals('active-widget', $widgets[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* 모듈 완전 삭제 시 Extension 영구 삭제 테스트
|
|
*/
|
|
public function test_module_uninstall_permanently_deletes_extensions(): void
|
|
{
|
|
// 1. 모듈 Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'header-actions',
|
|
'components' => [['id' => 'cart-icon', 'type' => 'basic', 'name' => 'Icon']],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. 모듈 비활성화 (soft delete)
|
|
$this->service->unregisterBySource(LayoutSourceType::Module, 'sirsoft-ecommerce');
|
|
|
|
// 3. 모듈 완전 제거 (force delete via repository)
|
|
$deleted = $this->repository->forceDeleteBySource(LayoutSourceType::Module, 'sirsoft-ecommerce');
|
|
|
|
$this->assertEquals(1, $deleted);
|
|
$this->assertEquals(0, LayoutExtension::withTrashed()->count());
|
|
}
|
|
|
|
/**
|
|
* 템플릿 삭제 시 관련 Extension 모두 삭제 테스트
|
|
*/
|
|
public function test_template_deletion_cascades_to_extensions(): void
|
|
{
|
|
// 1. Extension 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'extension_point' => 'header-actions',
|
|
'components' => [['id' => 'widget1', 'type' => 'basic', 'name' => 'Widget']],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin/dashboard',
|
|
'injections' => [
|
|
['target_id' => 'main', 'position' => 'append_child', 'components' => []],
|
|
],
|
|
],
|
|
LayoutSourceType::Plugin,
|
|
'sirsoft-analytics',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertEquals(2, LayoutExtension::count());
|
|
|
|
// 2. 템플릿 삭제
|
|
$this->template->delete();
|
|
|
|
// 3. 관련 Extension도 cascade 삭제됨
|
|
$this->assertEquals(0, LayoutExtension::count());
|
|
}
|
|
|
|
// =========================================================================
|
|
// inject_props + 섹션 병합 통합 테스트 (C-1 ~ C-7)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* ModuleManager/PluginManager를 모킹하여 테스트 식별자를 활성 상태로 설정
|
|
*
|
|
* @param array $moduleIdentifiers 활성 모듈 식별자 목록
|
|
* @param array $pluginIdentifiers 활성 플러그인 식별자 목록
|
|
*/
|
|
private function mockActiveExtensions(array $moduleIdentifiers, array $pluginIdentifiers): void
|
|
{
|
|
$activeModules = [];
|
|
foreach ($moduleIdentifiers as $identifier) {
|
|
$mock = $this->createMock(ModuleInterface::class);
|
|
$mock->method('getIdentifier')->willReturn($identifier);
|
|
$activeModules[$identifier] = $mock;
|
|
}
|
|
|
|
$mockModuleManager = $this->createMock(ModuleManager::class);
|
|
$mockModuleManager->method('getActiveModules')->willReturn($activeModules);
|
|
$this->app->instance(ModuleManager::class, $mockModuleManager);
|
|
|
|
$activePlugins = [];
|
|
foreach ($pluginIdentifiers as $identifier) {
|
|
$mock = $this->createMock(PluginInterface::class);
|
|
$mock->method('getIdentifier')->willReturn($identifier);
|
|
$activePlugins[$identifier] = $mock;
|
|
}
|
|
|
|
$mockPluginManager = $this->createMock(PluginManager::class);
|
|
$mockPluginManager->method('getActivePlugins')->willReturn($activePlugins);
|
|
$this->app->instance(PluginManager::class, $mockPluginManager);
|
|
|
|
// 모킹 후 서비스 재생성 (캐시된 활성 모듈 목록 초기화)
|
|
$this->service = $this->app->make(LayoutExtensionService::class);
|
|
}
|
|
|
|
/**
|
|
* C-1: inject_props overlay 등록 → 적용 E2E
|
|
*/
|
|
public function test_inject_props_registration_and_application_flow(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], []);
|
|
|
|
// 1. inject_props overlay 등록
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [
|
|
['id' => 'ext_verify', 'label' => 'Verification', 'iconName' => 'shield'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-support',
|
|
$this->template->id
|
|
);
|
|
|
|
// 2. DB 등록 확인
|
|
$this->assertDatabaseHas('template_layout_extensions', [
|
|
'extension_type' => LayoutExtensionType::Overlay->value,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_identifier' => 'sirsoft-support',
|
|
]);
|
|
|
|
// 3. 레이아웃 적용
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'user_tabs',
|
|
'type' => 'composite',
|
|
'name' => 'TabNavigation',
|
|
'props' => [
|
|
'tabs' => [
|
|
['id' => 'basic', 'label' => 'Basic'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
$tabs = $result['components'][0]['props']['tabs'];
|
|
$this->assertCount(2, $tabs);
|
|
$this->assertEquals('basic', $tabs[0]['id']);
|
|
$this->assertEquals('ext_verify', $tabs[1]['id']);
|
|
}
|
|
|
|
/**
|
|
* C-2: inject_props + append_child 복합 injection
|
|
*/
|
|
public function test_inject_props_and_component_injection_combined(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], []);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [
|
|
['id' => 'ext_verify', 'label' => 'Verification'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'target_id' => 'tab_content',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
[
|
|
'id' => 'ext_verify_content',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-support',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'user_tabs',
|
|
'type' => 'composite',
|
|
'name' => 'TabNavigation',
|
|
'props' => [
|
|
'tabs' => [['id' => 'basic']],
|
|
],
|
|
],
|
|
[
|
|
'id' => 'tab_content',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// inject_props 적용 확인
|
|
$tabs = $result['components'][0]['props']['tabs'];
|
|
$this->assertCount(2, $tabs);
|
|
$this->assertEquals('ext_verify', $tabs[1]['id']);
|
|
|
|
// append_child 적용 확인
|
|
$children = $result['components'][1]['children'];
|
|
$this->assertCount(1, $children);
|
|
$this->assertEquals('ext_verify_content', $children[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* C-3: 비활성 모듈의 inject_props 미적용
|
|
*/
|
|
public function test_inactive_module_inject_props_not_applied(): void
|
|
{
|
|
// 비활성 모듈의 확장 등록
|
|
LayoutExtension::create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'nonexistent-module',
|
|
'content' => [
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [['id' => 'ghost_tab']],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'user_tabs',
|
|
'type' => 'composite',
|
|
'name' => 'TabNavigation',
|
|
'props' => [
|
|
'tabs' => [['id' => 'basic']],
|
|
],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 비활성 모듈의 inject_props가 적용되지 않음
|
|
$tabs = $result['components'][0]['props']['tabs'];
|
|
$this->assertCount(1, $tabs);
|
|
$this->assertEquals('basic', $tabs[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* C-4: 여러 확장의 inject_props 순차 적용 (priority 순)
|
|
*/
|
|
public function test_multiple_inject_props_applied_by_priority_order(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], ['sirsoft-test']);
|
|
|
|
// 모듈 A (priority 100)
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [['id' => 'tab_a']],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'priority' => 100,
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-support',
|
|
$this->template->id
|
|
);
|
|
|
|
// 모듈 B (priority 200)
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [['id' => 'tab_b']],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'priority' => 200,
|
|
],
|
|
LayoutSourceType::Plugin,
|
|
'sirsoft-test',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'user_tabs',
|
|
'type' => 'composite',
|
|
'name' => 'TabNavigation',
|
|
'props' => [
|
|
'tabs' => [['id' => 'core']],
|
|
],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
$tabs = $result['components'][0]['props']['tabs'];
|
|
$this->assertCount(3, $tabs);
|
|
$this->assertEquals('core', $tabs[0]['id']);
|
|
$this->assertEquals('tab_a', $tabs[1]['id']);
|
|
$this->assertEquals('tab_b', $tabs[2]['id']);
|
|
}
|
|
|
|
/**
|
|
* C-5: overlay computed + modals 병합 E2E
|
|
*/
|
|
public function test_overlay_computed_and_modals_merge_e2e(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], []);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'container',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
['id' => 'ext_widget', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
],
|
|
],
|
|
'computed' => [
|
|
'extTabCount' => '{{3}}',
|
|
],
|
|
'modals' => [
|
|
['id' => 'ext_confirm_modal', 'type' => 'composite', 'name' => 'Modal'],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-support',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'container',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
'computed' => ['userName' => '{{user?.data?.name}}'],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// computed 병합 확인
|
|
$this->assertArrayHasKey('userName', $result['computed']);
|
|
$this->assertArrayHasKey('extTabCount', $result['computed']);
|
|
|
|
// modals 병합 확인
|
|
$this->assertArrayHasKey('modals', $result);
|
|
$modalIds = array_column($result['modals'], 'id');
|
|
$this->assertContains('ext_confirm_modal', $modalIds);
|
|
}
|
|
|
|
/**
|
|
* C-6: Extension Point modals + Overlay modals 동시 병합
|
|
*/
|
|
public function test_extension_point_and_overlay_modals_both_merged(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], ['sirsoft-payment']);
|
|
|
|
// Extension Point 확장 (modals 포함)
|
|
LayoutExtension::create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'user_detail_extensions',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-support',
|
|
'content' => [
|
|
'extension_point' => 'user_detail_extensions',
|
|
'components' => [
|
|
['id' => 'ep_widget', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
'modals' => [
|
|
['id' => 'ep_modal', 'type' => 'composite', 'name' => 'Modal'],
|
|
],
|
|
],
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// Overlay 확장 (modals 포함)
|
|
LayoutExtension::create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'sirsoft-payment',
|
|
'content' => [
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'container',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
['id' => 'ov_widget', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
],
|
|
],
|
|
'modals' => [
|
|
['id' => 'ov_modal', 'type' => 'composite', 'name' => 'Modal'],
|
|
],
|
|
],
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'type' => 'extension_point',
|
|
'name' => 'user_detail_extensions',
|
|
'default' => [],
|
|
],
|
|
[
|
|
'id' => 'container',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
$this->assertArrayHasKey('modals', $result);
|
|
$modalIds = array_column($result['modals'], 'id');
|
|
$this->assertContains('ep_modal', $modalIds);
|
|
$this->assertContains('ov_modal', $modalIds);
|
|
}
|
|
|
|
// =========================================================================
|
|
// extends 체인 base-타겟 overlay 전파 (E1 ~ E4)
|
|
//
|
|
// 배경: home(extends _user_base) 같은 자식 레이아웃을 서빙할 때, base(_user_base)
|
|
// 를 target_layout 으로 하는 overlay(예: 헤더 통화 슬롯 주입)가 적용되지 않던 결함.
|
|
// applyExtensions 가 $layout['layout_name'](="home") 으로만 overlay 를 매칭했기 때문.
|
|
// loadAndMergeLayout 가 부여하는 $layout['__extends_chain'] 을 매칭 대상에 포함시켜 해소.
|
|
// =========================================================================
|
|
|
|
/**
|
|
* E1: base(_user_base) 를 타겟한 overlay 가 자식(home) 서빙 시 적용된다
|
|
*/
|
|
public function test_base_targeted_overlay_applies_to_child_via_extends_chain(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// base 레이아웃(_user_base)을 타겟하는 overlay — 헤더 통화 슬롯 주입과 동형
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => '_user_base',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'header_currency_inject_anchor',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
['id' => 'ext_header_currency_selector', 'type' => 'basic', 'name' => 'Div', 'slot' => 'header_currency'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 자식(home) 서빙 시 loadAndMergeLayout 가 부여한 __extends_chain 을 가진 병합 결과
|
|
$layout = [
|
|
'layout_name' => 'home',
|
|
'__extends_chain' => ['_user_base'],
|
|
'components' => [
|
|
[
|
|
'id' => 'header_currency_inject_anchor',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// base-타겟 overlay 가 자식 서빙 결과에 주입됨
|
|
$anchorChildren = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(1, $anchorChildren, 'base(_user_base) overlay 가 자식(home)에 전파되어야 한다');
|
|
$this->assertEquals('ext_header_currency_selector', $anchorChildren[0]['id']);
|
|
$this->assertEquals('header_currency', $anchorChildren[0]['slot']);
|
|
|
|
// 내부 메타(__extends_chain)는 최종 응답에서 제거되어야 한다 (일반/편집 응답 노출 0)
|
|
$this->assertArrayNotHasKey('__extends_chain', $result, '__extends_chain 은 응답에서 제거되어야 한다');
|
|
}
|
|
|
|
/**
|
|
* E2: base overlay 의 init_actions 가 자식 서빙 시 호스트 init_actions 뒤에 병합된다
|
|
*/
|
|
public function test_base_overlay_init_actions_merge_into_child_via_extends_chain(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => '_user_base',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'header_currency_inject_anchor',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
['id' => 'ext_currency', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
],
|
|
],
|
|
'init_actions' => [
|
|
['handler' => 'sirsoft-ecommerce.initPreferredCurrency'],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'home',
|
|
'__extends_chain' => ['_user_base'],
|
|
'init_actions' => [
|
|
['handler' => 'hostInit'],
|
|
],
|
|
'components' => [
|
|
['id' => 'header_currency_inject_anchor', 'type' => 'basic', 'name' => 'Div', 'children' => []],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// 결과는 표준 키(initActions) 로 통합된다. deprecated 별칭(init_actions)이 함께 남으면
|
|
// 엔진의 `initActions || init_actions` 단락이 한쪽을 통째로 무시하므로 제거되어야 한다.
|
|
$handlers = array_column($result['initActions'] ?? [], 'handler');
|
|
$this->assertSame(['hostInit', 'sirsoft-ecommerce.initPreferredCurrency'], $handlers, '호스트 init_actions 뒤에 확장 init_actions 가 병합되어야 한다');
|
|
$this->assertArrayNotHasKey('init_actions', $result, 'deprecated 별칭 키가 남으면 통합 결과가 무시된다');
|
|
}
|
|
|
|
/**
|
|
* E3: 다단계 extends 체인 — 자식의 자식까지 base overlay 전파
|
|
*/
|
|
public function test_base_overlay_propagates_through_multi_level_extends_chain(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => '_user_base',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'anchor',
|
|
'position' => 'append_child',
|
|
'components' => [['id' => 'ext_node', 'type' => 'basic', 'name' => 'Div']],
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// shop_detail extends shop_base extends _user_base
|
|
$layout = [
|
|
'layout_name' => 'shop_detail',
|
|
'__extends_chain' => ['shop_base', '_user_base'],
|
|
'components' => [
|
|
['id' => 'anchor', 'type' => 'basic', 'name' => 'Div', 'children' => []],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
$children = $result['components'][0]['children'] ?? [];
|
|
$this->assertCount(1, $children, '다단계 체인 끝의 base overlay 도 전파되어야 한다');
|
|
$this->assertEquals('ext_node', $children[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* E4: 자식 자신을 타겟한 overlay 와 base 타겟 overlay 가 한 번씩만 적용된다(중복 방지)
|
|
*/
|
|
public function test_child_and_base_overlays_apply_once_each_without_duplication(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-ecommerce'], []);
|
|
|
|
// base(_user_base) 타겟 overlay
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => '_user_base',
|
|
'injections' => [
|
|
['target_id' => 'anchor', 'position' => 'append_child', 'components' => [['id' => 'base_node', 'type' => 'basic', 'name' => 'Div']]],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 자식(home) 자신을 타겟한 overlay
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'home',
|
|
'injections' => [
|
|
['target_id' => 'anchor', 'position' => 'append_child', 'components' => [['id' => 'child_node', 'type' => 'basic', 'name' => 'Div']]],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'home',
|
|
'__extends_chain' => ['_user_base'],
|
|
'components' => [
|
|
['id' => 'anchor', 'type' => 'basic', 'name' => 'Div', 'children' => []],
|
|
],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
$childIds = array_column($result['components'][0]['children'] ?? [], 'id');
|
|
sort($childIds);
|
|
$this->assertEquals(['base_node', 'child_node'], $childIds, '자식·base overlay 가 각각 한 번씩만 적용되어야 한다(중복 없음)');
|
|
}
|
|
|
|
/**
|
|
* C-7: inject_props + overlay state/computed 종합 시나리오
|
|
*/
|
|
public function test_inject_props_with_state_and_computed_comprehensive(): void
|
|
{
|
|
$this->mockActiveExtensions(['sirsoft-support'], []);
|
|
|
|
$this->service->registerExtension(
|
|
[
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
[
|
|
'target_id' => 'user_tabs',
|
|
'position' => 'inject_props',
|
|
'props' => [
|
|
'tabs' => [
|
|
'_append' => [
|
|
['id' => 'ext_verify', 'label' => 'Verify'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'target_id' => 'tab_content',
|
|
'position' => 'append_child',
|
|
'components' => [
|
|
['id' => 'verify_content', 'type' => 'basic', 'name' => 'Div'],
|
|
],
|
|
],
|
|
],
|
|
'state' => [
|
|
'verificationData' => null,
|
|
],
|
|
'computed' => [
|
|
'isVerified' => '{{user?.data?.identity_verified ?? false}}',
|
|
],
|
|
'data_sources' => [
|
|
[
|
|
'id' => 'verification_info',
|
|
'type' => 'api',
|
|
'endpoint' => '/api/verification/{{route.id}}',
|
|
],
|
|
],
|
|
],
|
|
LayoutSourceType::Module,
|
|
'sirsoft-support',
|
|
$this->template->id
|
|
);
|
|
|
|
$layout = [
|
|
'layout_name' => 'admin_user_detail',
|
|
'components' => [
|
|
[
|
|
'id' => 'user_tabs',
|
|
'type' => 'composite',
|
|
'name' => 'TabNavigation',
|
|
'props' => [
|
|
'tabs' => [['id' => 'basic']],
|
|
],
|
|
],
|
|
[
|
|
'id' => 'tab_content',
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [],
|
|
],
|
|
],
|
|
'data_sources' => [
|
|
['id' => 'user', 'type' => 'api', 'endpoint' => '/api/users/{{route.id}}'],
|
|
],
|
|
'state' => ['loading' => false],
|
|
'computed' => ['userName' => '{{user?.data?.name}}'],
|
|
];
|
|
|
|
$result = $this->service->applyExtensions($layout, $this->template->id);
|
|
|
|
// inject_props 확인
|
|
$tabs = $result['components'][0]['props']['tabs'];
|
|
$this->assertCount(2, $tabs);
|
|
$this->assertEquals('ext_verify', $tabs[1]['id']);
|
|
|
|
// component injection 확인
|
|
$children = $result['components'][1]['children'];
|
|
$this->assertCount(1, $children);
|
|
$this->assertEquals('verify_content', $children[0]['id']);
|
|
|
|
// state 병합 확인
|
|
$this->assertFalse($result['state']['loading']);
|
|
$this->assertNull($result['state']['verificationData']);
|
|
|
|
// computed 병합 확인
|
|
$this->assertArrayHasKey('userName', $result['computed']);
|
|
$this->assertArrayHasKey('isVerified', $result['computed']);
|
|
|
|
// data_sources 병합 확인
|
|
$dsIds = array_column($result['data_sources'], 'id');
|
|
$this->assertContains('user', $dsIds);
|
|
$this->assertContains('verification_info', $dsIds);
|
|
}
|
|
}
|