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']); } /** * 부모(base) 레이아웃 생성 — 슬롯 + init_actions 보유. */ private function makeParent(array $initActions): TemplateLayout { return TemplateLayout::factory()->create([ 'template_id' => $this->template->id, 'name' => '_user_base', 'content' => [ 'version' => '1.0.0', 'layout_name' => '_user_base', 'components' => [ ['id' => 'shell', 'name' => 'Div', 'type' => 'basic', 'props' => [], 'children' => [ ['id' => 'content_slot', 'name' => 'Div', 'type' => 'basic', 'slot' => 'content', 'props' => []], ]], ], 'data_sources' => [], 'init_actions' => $initActions, ], 'extends' => null, 'lock_version' => 0, ]); } /** * 자식 레이아웃 생성 — 부모를 extends, init_actions 보유. */ private function makeChild(array $initActions): TemplateLayout { return TemplateLayout::factory()->create([ 'template_id' => $this->template->id, 'name' => 'home', 'content' => [ 'version' => '1.0.0', 'layout_name' => 'home', 'extends' => '_user_base', 'slots' => [ 'content' => [ ['id' => 'root', 'name' => 'Div', 'type' => 'basic', 'props' => []], ], ], 'data_sources' => [], 'init_actions' => $initActions, ], 'extends' => '_user_base', 'lock_version' => 0, ]); } /** * 자식 레이아웃에 content 를 PUT 하고 갱신된 DB content 를 반환. * * @param array $content * @return array */ private function putChild(TemplateLayout $child, array $content): array { $response = $this->authRequest()->putJson( "/api/admin/templates/{$this->template->identifier}/layouts/{$child->name}", ['expected_lock_version' => 0, 'content' => $content], ); $response->assertStatus(200); $child->refresh(); return $child->content; } /** * 자식 PUT 의 기본 content 골격(extends + slots + 자식 init_actions). * * @param array> $childInitActions * @return array */ private function childContent(array $childInitActions): array { return [ 'version' => '1.0.0', 'layout_name' => 'home', 'extends' => '_user_base', 'slots' => [ 'content' => [ ['id' => 'root', 'name' => 'Div', 'type' => 'basic', 'props' => []], ], ], 'data_sources' => [], 'init_actions' => $childInitActions, ]; } #[Test] public function it_persists_only_child_init_actions_on_save(): void { // ① 자식 [화면동작] 편집 후 PUT → 자식 init_actions 만 (부모분 미포함, __source 부재) // ② DB 저장된 자식 content.init_actions = 자식 원본만 (부모 복제 0) $this->makeParent([['handler' => 'initTheme'], ['handler' => 'initCartKey']]); $child = $this->makeChild([['handler' => 'toast']]); $childActions = [ ['handler' => 'toast', 'params' => ['message' => 'hi']], ['handler' => 'setState', 'target' => 'local', 'params' => ['ready' => true]], ]; $stored = $this->putChild($child, $this->childContent($childActions)); $this->assertArrayHasKey('init_actions', $stored); $this->assertCount(2, $stored['init_actions'], '자식 init_actions 만 저장되어야 함(부모 복제 0)'); $this->assertSame('toast', $stored['init_actions'][0]['handler']); $this->assertSame('setState', $stored['init_actions'][1]['handler']); // 부모 핸들러(initTheme/initCartKey)는 자식 content 에 없어야 함 $handlers = array_column($stored['init_actions'], 'handler'); $this->assertNotContains('initTheme', $handlers, '부모 init_action 이 자식에 영속되면 안 됨'); $this->assertNotContains('initCartKey', $handlers); // __source 메타가 페이로드에 섞이지 않음 foreach ($stored['init_actions'] as $item) { $this->assertArrayNotHasKey('__source', $item, '편집 전용 __source 메타가 저장되면 안 됨'); } } #[Test] public function it_remerges_parent_and_reattaches_source_meta_on_reload(): void { // ③ 저장 후 재로드(with_source_meta=1) → 부모+자식 다시 병합·메타 재부착(영속 아님) $this->makeParent([['handler' => 'initTheme'], ['handler' => 'initCartKey']]); $child = $this->makeChild([['handler' => 'toast']]); // 자식 저장(자식 init_actions 만) $this->putChild($child, $this->childContent([['handler' => 'toast']])); // DB content 자체에는 부모분·__source 가 없다(영속 아님) $child->refresh(); $this->assertCount(1, $child->content['init_actions']); $this->assertArrayNotHasKey('__source', $child->content['init_actions'][0]); // 편집 모드 재로드 → 부모 먼저 + 자식 나중 순서로 병합, __source 메타 재부착 $merged = app(LayoutService::class)->loadAndMergeLayout($this->template->id, 'home', true); $this->assertArrayHasKey('initActions', $merged, '병합본은 initActions 키로 노출'); $this->assertCount(3, $merged['initActions'], '부모 2 + 자식 1 = 3 병합'); $this->assertSame('initTheme', $merged['initActions'][0]['handler']); $this->assertSame('initCartKey', $merged['initActions'][1]['handler']); $this->assertSame('toast', $merged['initActions'][2]['handler']); // __source 재부착: 부모=base/_user_base, 자식=route/home $this->assertSame(['kind' => 'base', 'layout' => '_user_base'], $merged['initActions'][0]['__source']); $this->assertSame(['kind' => 'base', 'layout' => '_user_base'], $merged['initActions'][1]['__source']); $this->assertSame(['kind' => 'route', 'layout' => 'home'], $merged['initActions'][2]['__source']); // 자식 원본은 __editor.original 에 별도 보존(자식분만) $this->assertArrayHasKey('__editor', $merged); $this->assertCount(1, $merged['__editor']['original']['init_actions']); $this->assertSame('toast', $merged['__editor']['original']['init_actions'][0]['handler']); } #[Test] public function it_persists_parent_init_actions_when_editing_base_layout(): void { // ④ base 편집 모드 저장 → 부모(_user_base) 행에 부모 init_actions 영속 $parent = $this->makeParent([['handler' => 'initTheme']]); $this->makeChild([['handler' => 'toast']]); $newParentActions = [ ['handler' => 'initTheme'], ['handler' => 'initCartKey'], ]; $response = $this->authRequest()->putJson( "/api/admin/templates/{$this->template->identifier}/layouts/{$parent->name}", ['expected_lock_version' => 0, 'content' => [ 'version' => '1.0.0', 'layout_name' => '_user_base', 'components' => [ ['id' => 'shell', 'name' => 'Div', 'type' => 'basic', 'props' => [], 'children' => [ ['id' => 'content_slot', 'name' => 'Div', 'type' => 'basic', 'slot' => 'content', 'props' => []], ]], ], 'data_sources' => [], 'init_actions' => $newParentActions, ]], ); $response->assertStatus(200); $parent->refresh(); $this->assertArrayHasKey('init_actions', $parent->content); $this->assertCount(2, $parent->content['init_actions']); $this->assertSame('initTheme', $parent->content['init_actions'][0]['handler']); $this->assertSame('initCartKey', $parent->content['init_actions'][1]['handler']); } #[Test] public function it_leaves_parent_row_unchanged_when_child_is_saved(): void { // ④ 보강 — 자식 저장이 부모 행을 변경하지 않음 $parent = $this->makeParent([['handler' => 'initTheme'], ['handler' => 'initCartKey']]); $child = $this->makeChild([['handler' => 'toast']]); $this->putChild($child, $this->childContent([ ['handler' => 'toast'], ['handler' => 'navigate', 'params' => ['path' => '/']], ])); $parent->refresh(); $this->assertCount(2, $parent->content['init_actions'], '부모 행 init_actions 불변'); $this->assertSame('initTheme', $parent->content['init_actions'][0]['handler']); $this->assertSame('initCartKey', $parent->content['init_actions'][1]['handler']); $this->assertSame(0, (int) $parent->lock_version, '부모 lock_version 불변'); } #[Test] public function it_ignores_forged_source_meta_in_child_payload_on_reload(): void { // ⑤ 위조 PUT — 자식 content 에 __source 가 박힌 부모 init_action 을 섞어 보냄. // init_actions 는 노드 트리가 아니라 최상위 배열이라 백엔드 노드 마스킹 대상이 아니다. // 백엔드는 배열을 그대로 적재하지만(거부하지 않음), 부모분 제외의 SSoT 는 클라이언트의 // __editor.original 이다. 본 테스트는 "편집 전용 __source 가 페이로드에 섞여 저장돼도 // 재로드/운영 동작이 깨지지 않는다"는 백엔드 계약을 가드한다(plan dispatch 안전성). $this->makeParent([['handler' => 'initTheme']]); $child = $this->makeChild([['handler' => 'toast']]); $forged = [ // 부모 항목을 __source 메타까지 그대로 위조해 자식 content 에 포함 ['handler' => 'initTheme', '__source' => ['kind' => 'base', 'layout' => '_user_base']], ['handler' => 'toast', '__source' => ['kind' => 'route', 'layout' => 'home']], ]; $stored = $this->putChild($child, $this->childContent($forged)); // 백엔드는 최상위 배열을 통째 보존(R9) — 거부하지 않음 $this->assertCount(2, $stored['init_actions']); // 재로드(with_source_meta=1) 시 부모분이 다시 앞에 붙고 __source 가 재계산된다. // 위조로 박힌 __source 가 운영 dispatch 화이트리스트에 미포함이라 런타임 무영향이며, // 재로드 병합은 위조 항목을 자식분(route)으로 취급해 부모(_user_base) 항목을 그 앞에 붙인다. $merged = app(LayoutService::class)->loadAndMergeLayout($this->template->id, 'home', true); // 부모 1 + 자식 2(위조 포함) = 3 $this->assertCount(3, $merged['initActions']); // 부모 항목이 가장 앞 + base 메타로 재스탬프(위조 메타에 가려지지 않음) $this->assertSame('initTheme', $merged['initActions'][0]['handler']); $this->assertSame(['kind' => 'base', 'layout' => '_user_base'], $merged['initActions'][0]['__source']); // 자식 구간(위조 항목 포함)은 route/home 으로 재스탬프 $this->assertSame(['kind' => 'route', 'layout' => 'home'], $merged['initActions'][1]['__source']); $this->assertSame(['kind' => 'route', 'layout' => 'home'], $merged['initActions'][2]['__source']); } }