Files
Gnuboard7/tests/Feature/Api/Admin/LayoutControllerTopLevelPatchTest.php
2026-07-01 10:30:32 +09:00

305 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* LayoutControllerTopLevelPatchTest — 최상위/중첩 키 validated 보존 회귀 가드 (R9)
*
* 페이지 설정 탭이 패치하는 최상위 키(errorHandling/initIsolated/transition_overlay) 와
* data_sources nested 키(onSuccess/onError/errorHandling/contentType/initLocal/initIsolated/
* if/conditions/refetchOnMount/channel/event/channel_type/target_source/onReceive) 가 PUT →
* DB 라운드트립에서 `validated()` 에 의해 탈락되지 않음을 전수 가드한다.
*
* 근거: `content.data_sources` 는 `['array', ValidDataSourceMerge]` 로 `.*` 하위 규칙이 없어
* Laravel validated() 가 배열을 통째 보존한다(노드 내부 미가공). `content.errorHandling` 동일.
* `content.initIsolated` 는 본 작업에서 신규 명시(미명시 시 누락 — 이 테스트가 가드).
*/
class LayoutControllerTopLevelPatchTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private Template $template;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->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 오염 금지');
}
}