adminUser = $this->createAdminUser(['core.templates.read', 'core.templates.layouts.edit']); $this->token = $this->adminUser->createToken('test-token')->plainTextToken; $this->template = Template::factory()->create(); } private function createAdminUser(array $permissions = []): User { $user = User::factory()->create(); $permissionIds = []; foreach ($permissions as $identifier) { $permission = Permission::firstOrCreate( ['identifier' => $identifier], [ 'name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'description' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'extension_type' => ExtensionOwnerType::Core, ] ); $permissionIds[] = $permission->id; } $adminRole = Role::firstOrCreate( ['identifier' => 'admin'], [ 'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']), 'extension_type' => ExtensionOwnerType::Core, 'is_system' => true, 'priority' => 0, ] ); $adminRole->permissions()->syncWithoutDetaching($permissionIds); $user->roles()->syncWithoutDetaching([$adminRole->id]); return $user; } private function authRequest() { return $this->withHeaders(['Authorization' => 'Bearer '.$this->token, 'Accept' => 'application/json']); } private function makeLayout(): TemplateLayout { return TemplateLayout::factory()->create([ 'template_id' => $this->template->id, 'name' => 'patch-target', 'content' => [ 'version' => '1.0.0', 'layout_name' => 'patch-target', 'endpoint' => '/api/admin/test', 'components' => [], 'data_sources' => [], ], 'lock_version' => 0, ]); } private function putContent(TemplateLayout $layout, array $contentOverrides): array { $content = array_merge([ 'version' => '1.0.0', 'layout_name' => 'patch-target', 'endpoint' => '/api/admin/test', 'components' => [ ['id' => 'root', 'name' => 'Div', 'type' => 'basic', 'props' => []], ], 'data_sources' => [], ], $contentOverrides); $response = $this->authRequest()->putJson( "/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}", ['expected_lock_version' => 0, 'content' => $content], ); $response->assertStatus(200); $layout->refresh(); return $layout->content; } #[Test] public function it_preserves_data_source_nested_keys_on_save(): void { $layout = $this->makeLayout(); $ds = [ 'id' => 'products', 'type' => 'api', 'endpoint' => '/api/products', 'contentType' => 'multipart/form-data', 'refetchOnMount' => true, 'onSuccess' => [['handler' => 'setState', 'target' => 'local', 'params' => ['loaded' => true]]], 'onError' => [['handler' => 'toast', 'params' => ['message' => '{{error.message}}']]], 'errorHandling' => ['403' => ['handler' => 'showErrorPage', 'target' => '403']], 'errorCondition' => ['if' => '{{response?.data?.x !== true}}', 'errorCode' => 403], 'initLocal' => 'form', 'initGlobal' => ['key' => 'cartCount', 'path' => 'count'], 'initIsolated' => 'sliderState', 'if' => '{{ route.id }}', 'conditions' => [['field' => 'x', 'op' => '==', 'value' => 1]], ]; $stored = $this->putContent($layout, ['data_sources' => [$ds]]); $savedDs = $stored['data_sources'][0]; foreach (['contentType', 'refetchOnMount', 'onSuccess', 'onError', 'errorHandling', 'errorCondition', 'initLocal', 'initGlobal', 'initIsolated', 'if', 'conditions'] as $key) { $this->assertArrayHasKey($key, $savedDs, "data_source.$key 가 저장 시 탈락됨 (R9)"); } $this->assertSame('multipart/form-data', $savedDs['contentType']); $this->assertTrue($savedDs['refetchOnMount']); $this->assertCount(1, $savedDs['onSuccess']); $this->assertSame('showErrorPage', $savedDs['errorHandling']['403']['handler']); } #[Test] public function it_preserves_websocket_data_source_keys_on_save(): void { $layout = $this->makeLayout(); $ds = [ 'id' => 'live', 'type' => 'websocket', 'channel' => 'core.admin.dashboard', 'event' => 'dashboard.stats.updated', 'channel_type' => 'private', 'target_source' => 'dashboard_stats', 'onReceive' => [['handler' => 'refetchDataSource', 'params' => ['dataSourceId' => 'dashboard_stats']]], ]; $stored = $this->putContent($layout, ['data_sources' => [$ds]]); $savedDs = $stored['data_sources'][0]; foreach (['channel', 'event', 'channel_type', 'target_source', 'onReceive'] as $key) { $this->assertArrayHasKey($key, $savedDs, "websocket data_source.$key 탈락 (R9)"); } $this->assertSame('private', $savedDs['channel_type']); } #[Test] public function it_preserves_top_level_error_handling_codes_on_save(): void { $layout = $this->makeLayout(); $stored = $this->putContent($layout, [ 'errorHandling' => [ '403' => ['handler' => 'showErrorPage', 'target' => '403'], 'default' => ['handler' => 'toast', 'params' => ['message' => '{{error.message}}']], ], ]); $this->assertArrayHasKey('errorHandling', $stored); $this->assertSame('showErrorPage', $stored['errorHandling']['403']['handler']); $this->assertSame('toast', $stored['errorHandling']['default']['handler']); } #[Test] public function it_preserves_node_top_level_data_key_on_save(): void { // dataKey 는 노드 **최상위** 구조 키(`node.dataKey`, props 아님 —/DynamicRenderer.tsx:2470). // 저장 경로에서 `content.components` 는 `['array']`(하위 `.*` 규칙 없음)라 validated() 가 // 컴포넌트 트리를 통째 보존하고, `stripInheritedNode` 는 `__source`/`_fromBase` 만 제거하므로 // dataKey 는 살아남는다. 누군가 `content.components.*` 하위 규칙을 추가하면 dataKey 가 조용히 // 떨어지는 회귀가 발생하므로 그 회귀를 본 가드가 차단한다(if/actions 등 다른 노드 최상위 키와 동일 부류). $layout = $this->makeLayout(); $stored = $this->putContent($layout, [ 'components' => [ [ 'id' => 'order_form', 'name' => 'Form', 'type' => 'basic', 'dataKey' => 'form', 'props' => [], 'children' => [ ['id' => 'inner', 'name' => 'Div', 'type' => 'basic', 'dataKey' => '_global.formData', 'props' => []], ], ], ], ]); $this->assertArrayHasKey('dataKey', $stored['components'][0], 'node.dataKey 가 저장 시 탈락됨'); $this->assertSame('form', $stored['components'][0]['dataKey']); // props 오염 0 가드 — dataKey 가 props 로 새지 않음 $this->assertArrayNotHasKey('dataKey', $stored['components'][0]['props'] ?? [], 'dataKey 가 props 로 새면 안 됨'); // 중첩 자식의 노드 최상위 dataKey(접두 표현식 포함)도 보존 $this->assertSame('_global.formData', $stored['components'][0]['children'][0]['dataKey']); } #[Test] public function it_preserves_top_level_init_isolated_on_save(): void { // initIsolated 는 본 작업 신규 명시 키 — 미명시였다면 validated() 가 떨궜다(R9 가드). $layout = $this->makeLayout(); $stored = $this->putContent($layout, [ 'initIsolated' => ['sliderState' => ['idx' => 0], 'wizard' => ['step' => 1]], ]); $this->assertArrayHasKey('initIsolated', $stored); $this->assertSame(0, $stored['initIsolated']['sliderState']['idx']); $this->assertSame(1, $stored['initIsolated']['wizard']['step']); } #[Test] public function it_preserves_top_level_computed_on_save(): void { // [자동 계산] 탭이 패치하는 최상위 computed — `content.computed` 는 `['array']`(하위 `.*` // 규칙 없음)라 validated() 가 맵을 통째 보존. 누군가 하위 규칙을 추가하면 키가 조용히 // 떨어지는 회귀를 차단한다(부모/자식 병합본 저장 라운드트립). $layout = $this->makeLayout(); $stored = $this->putContent($layout, [ 'computed' => [ 'isReadOnly' => '{{ _local.forced ?? false }}', 'total' => '{{ products.data.data.length }}', ], ]); $this->assertArrayHasKey('computed', $stored); $this->assertSame('{{ _local.forced ?? false }}', $stored['computed']['isReadOnly']); $this->assertSame('{{ products.data.data.length }}', $stored['computed']['total']); } #[Test] public function it_preserves_top_level_transition_overlay_on_save(): void { // [로딩 화면] 탭이 패치하는 최상위 transition_overlay — 하위 키가 rules 에 명시돼 있어 // validated() 가 명시 하위(enabled/style/target/fallback_target/skeleton.*/spinner.*/ // wait_for)를 보존한다. 명시 누락 하위 키가 생기면 조용히 떨어지는 회귀를 차단. $layout = $this->makeLayout(); // wait_for 는 progressive 데이터소스만 허용(withValidator) — 동반 data_source 를 둔다. $stored = $this->putContent($layout, [ 'data_sources' => [ ['id' => 'products', 'type' => 'api', 'endpoint' => '/api/products', 'loading_strategy' => 'progressive'], ], 'transition_overlay' => [ 'enabled' => true, 'style' => 'skeleton', 'target' => 'main-content', 'skeleton' => ['component' => 'PageSkeleton', 'animation' => 'wave', 'iteration_count' => 3], 'wait_for' => ['products'], ], ]); $this->assertArrayHasKey('transition_overlay', $stored); $ov = $stored['transition_overlay']; $this->assertTrue($ov['enabled']); $this->assertSame('skeleton', $ov['style']); $this->assertSame('main-content', $ov['target']); $this->assertSame('PageSkeleton', $ov['skeleton']['component']); $this->assertSame('wave', $ov['skeleton']['animation']); $this->assertSame(3, $ov['skeleton']['iteration_count']); $this->assertSame(['products'], $ov['wait_for']); } #[Test] public function it_preserves_node_top_level_isolated_state_on_save(): void { // [격리 영역] 컨트롤이 부여하는 node.isolatedState / node.isolatedScopeId 는 노드 최상위 // 구조 키(props 아님). dataKey 와 동일하게 components 트리 통째 보존으로 살아남는다. $layout = $this->makeLayout(); $stored = $this->putContent($layout, [ 'components' => [ [ 'id' => 'scroll_area', 'name' => 'Div', 'type' => 'basic', 'isolatedState' => ['idx' => 0], 'isolatedScopeId' => 'scroll_area-scope', 'props' => [], ], ], ]); $this->assertArrayHasKey('isolatedState', $stored['components'][0], 'node.isolatedState 탈락'); $this->assertArrayHasKey('isolatedScopeId', $stored['components'][0], 'node.isolatedScopeId 탈락'); $this->assertSame('scroll_area-scope', $stored['components'][0]['isolatedScopeId']); $this->assertArrayNotHasKey('isolatedState', $stored['components'][0]['props'] ?? [], 'props 오염 금지'); } }