579 lines
22 KiB
PHP
579 lines
22 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Models\LayoutExtension;
|
|
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;
|
|
|
|
/**
|
|
* 레이아웃 편집기 저장 흐름 — 낙관적 잠금
|
|
*
|
|
* 코드 편집기 ↔ 레이아웃 편집기 동시 저장 시 silent overwrite 방지를 위한
|
|
* `lock_version` + `expected_lock_version` 흐름을 검증합니다.
|
|
*
|
|
* @see tests/scenarios/layout-optimistic-lock.yaml
|
|
*/
|
|
class LayoutControllerEditorSaveTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected array $requiredExtensions = [
|
|
'plugins/sirsoft-gdpr',
|
|
];
|
|
|
|
private User $adminUser;
|
|
|
|
private User $normalUser;
|
|
|
|
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->normalUser = User::factory()->create();
|
|
$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(int $lockVersion = 0): TemplateLayout
|
|
{
|
|
return TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'editor-target',
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'editor-target',
|
|
'endpoint' => '/api/admin/test',
|
|
'components' => [],
|
|
'data_sources' => [],
|
|
],
|
|
'lock_version' => $lockVersion,
|
|
]);
|
|
}
|
|
|
|
private function defaultUpdatePayload(int $expectedLockVersion): array
|
|
{
|
|
return [
|
|
'expected_lock_version' => $expectedLockVersion,
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'editor-target',
|
|
'endpoint' => '/api/admin/test',
|
|
'components' => [
|
|
[
|
|
'id' => 'edited',
|
|
'name' => 'Div',
|
|
'type' => 'basic',
|
|
'props' => ['className' => 'p-4'],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
],
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
public function it_returns_200_and_increments_lock_version_when_expected_matches(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$this->defaultUpdatePayload(expectedLockVersion: 0),
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertEquals(1, $response->json('data.lock_version'));
|
|
|
|
$layout->refresh();
|
|
$this->assertEquals(1, $layout->lock_version);
|
|
}
|
|
|
|
/**
|
|
* (D13) — 편집기 색 모드 × 디바이스 스타일 편집은 node 에 `responsive` /
|
|
* `if` (최상위) 키를 추가한다. ValidLayoutStructure 는 화이트리스트가 아니라 형식
|
|
* 검증이므로 미언급 키를 거부하지 않고, content.components 는 'array' 규칙으로 통째
|
|
* 보존된다(validated() drop 은 meta/seo 등 nested 명시 키에만 해당 — node 내부 아님).
|
|
* 저장 → DB 재로드 시 responsive/if 가 그대로 보존됨을 가드한다(백엔드 코드 변경 없음).
|
|
*/
|
|
#[Test]
|
|
public function it_preserves_node_responsive_and_if_overrides_on_save_and_reload(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$payload = $this->defaultUpdatePayload(expectedLockVersion: 0);
|
|
$payload['content']['components'] = [
|
|
[
|
|
'id' => 'scoped-node',
|
|
'name' => 'Div',
|
|
'type' => 'basic',
|
|
'props' => ['className' => 'bg-white dark:bg-slate-800'],
|
|
'if' => '{{ _global?.currentUser?.uuid }}',
|
|
'responsive' => [
|
|
'mobile' => [
|
|
'props' => ['className' => 'block'],
|
|
'if' => '{{ query?.tab === "m" }}',
|
|
],
|
|
'600-900' => [
|
|
'props' => ['className' => 'flex'],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
// DB 재로드 — responsive/if 가 그대로 보존되어야 한다.
|
|
$node = $layout->fresh()->content['components'][0];
|
|
|
|
$this->assertSame('{{ _global?.currentUser?.uuid }}', $node['if'], '최상위 if 보존');
|
|
$this->assertArrayHasKey('responsive', $node, 'responsive 브랜치 보존');
|
|
$this->assertSame('block', $node['responsive']['mobile']['props']['className']);
|
|
$this->assertSame('{{ query?.tab === "m" }}', $node['responsive']['mobile']['if']);
|
|
$this->assertSame('flex', $node['responsive']['600-900']['props']['className']);
|
|
// 라이트/다크 공존 className 도 보존
|
|
$this->assertSame('bg-white dark:bg-slate-800', $node['props']['className']);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_returns_409_when_expected_version_is_stale(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 5);
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$this->defaultUpdatePayload(expectedLockVersion: 3),
|
|
);
|
|
|
|
$response->assertStatus(409);
|
|
$this->assertEquals('concurrent_modification', $response->json('errors.error'));
|
|
$this->assertEquals(5, $response->json('errors.current_version'));
|
|
$this->assertEquals(3, $response->json('errors.your_version'));
|
|
|
|
$layout->refresh();
|
|
$this->assertEquals(5, $layout->lock_version, 'lock_version 은 충돌 시 증가하지 않아야 한다');
|
|
}
|
|
|
|
#[Test]
|
|
public function it_returns_422_when_expected_lock_version_is_missing(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$payload = $this->defaultUpdatePayload(expectedLockVersion: 0);
|
|
unset($payload['expected_lock_version']);
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['expected_lock_version']);
|
|
}
|
|
|
|
#[Test]
|
|
public function concurrent_save_only_first_client_succeeds(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
// 두 클라이언트 모두 lock_version=0 를 로드한 상태
|
|
$payloadA = $this->defaultUpdatePayload(expectedLockVersion: 0);
|
|
$payloadA['content']['components'][0]['id'] = 'edit_by_A';
|
|
|
|
$payloadB = $this->defaultUpdatePayload(expectedLockVersion: 0);
|
|
$payloadB['content']['components'][0]['id'] = 'edit_by_B';
|
|
|
|
// 첫 번째 저장 — 성공
|
|
$responseA = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payloadA,
|
|
);
|
|
$responseA->assertStatus(200);
|
|
|
|
// 두 번째 저장 — 같은 expected=0 으로 시도, current=1 이므로 409
|
|
$responseB = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payloadB,
|
|
);
|
|
$responseB->assertStatus(409);
|
|
|
|
$layout->refresh();
|
|
$this->assertEquals(1, $layout->lock_version);
|
|
// A 의 변경이 반영되어 있어야 한다
|
|
$this->assertEquals('edit_by_A', $layout->content['components'][0]['id']);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_exposes_lock_version_in_layout_resource(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 7);
|
|
|
|
$response = $this->authRequest()->getJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertEquals(7, $response->json('data.lock_version'));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Phase 3 S5a-2 결함 I — 편집기 응답 메타 노드 마스킹 백엔드 2차 가드
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[Test]
|
|
public function it_strips_base_and_extension_and_partial_nodes_from_payload(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$payload = [
|
|
'expected_lock_version' => 0,
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'editor-target',
|
|
'endpoint' => '/api/admin/test',
|
|
// 편집기 응답이 그대로 전송된 케이스 — base/extension/partial 노드가 섞여 있음
|
|
'components' => [
|
|
[
|
|
'id' => 'base-header',
|
|
'name' => 'Div',
|
|
'type' => 'basic',
|
|
'_fromBase' => true,
|
|
'__source' => ['kind' => 'base', 'layout' => '_base'],
|
|
],
|
|
[
|
|
'id' => 'ext-banner',
|
|
'name' => 'AdBanner',
|
|
'type' => 'composite',
|
|
'__source' => ['kind' => 'extension', 'extensionId' => 7],
|
|
],
|
|
[
|
|
'id' => 'partial-fragment',
|
|
'name' => 'Section',
|
|
'type' => 'basic',
|
|
'__source' => ['kind' => 'partial'],
|
|
],
|
|
[
|
|
'id' => 'route-main',
|
|
'name' => 'Main',
|
|
'type' => 'basic',
|
|
'__source' => ['kind' => 'route', 'layout' => 'editor-target'],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$layout->refresh();
|
|
$components = $layout->content['components'];
|
|
|
|
// base / extension / partial 출처 노드 3건은 제거되어 1건만 남아야 한다
|
|
$this->assertCount(1, $components, 'base/extension/partial 출처 노드는 저장 전에 제거되어야 한다');
|
|
$this->assertEquals('route-main', $components[0]['id']);
|
|
// 살아남은 route 노드의 __source 메타도 제거되어야 한다 (저장 형식 = 원본 형식)
|
|
$this->assertArrayNotHasKey('__source', $components[0]);
|
|
$this->assertArrayNotHasKey('_fromBase', $components[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_strips_inherited_nodes_recursively_inside_route_children(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$payload = [
|
|
'expected_lock_version' => 0,
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'editor-target',
|
|
'endpoint' => '/api/admin/test',
|
|
'components' => [
|
|
[
|
|
'id' => 'route-main',
|
|
'name' => 'Main',
|
|
'type' => 'basic',
|
|
'__source' => ['kind' => 'route', 'layout' => 'editor-target'],
|
|
'children' => [
|
|
[
|
|
'id' => 'keep-me',
|
|
'name' => 'P',
|
|
'type' => 'basic',
|
|
],
|
|
[
|
|
'id' => 'inherited-from-base',
|
|
'name' => 'Header',
|
|
'type' => 'basic',
|
|
'_fromBase' => true,
|
|
],
|
|
[
|
|
'id' => 'injected-from-ext',
|
|
'name' => 'AdBanner',
|
|
'type' => 'composite',
|
|
'__source' => ['kind' => 'extension', 'extensionId' => 3],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$layout->refresh();
|
|
$components = $layout->content['components'];
|
|
$this->assertCount(1, $components);
|
|
$children = $components[0]['children'];
|
|
$this->assertCount(1, $children, 'route 노드 안의 base/extension 자손도 재귀 제거되어야 한다');
|
|
$this->assertEquals('keep-me', $children[0]['id']);
|
|
$this->assertArrayNotHasKey('_fromBase', $children[0]);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_uses_editor_original_container_when_present_for_extends_child(): void
|
|
{
|
|
// extends 자식 레이아웃을 만들고, 편집 모드 응답이 __editor.original 으로 자식 원본을 동봉한 케이스.
|
|
// 백엔드 2차 가드가 그 원본을 SSoT 로 사용해 저장한다.
|
|
$baseLayout = TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => '_base',
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => '_base',
|
|
'endpoint' => '/api/admin/test',
|
|
'components' => [
|
|
['type' => 'basic', 'name' => 'Slot', 'slot' => 'main'],
|
|
],
|
|
],
|
|
'lock_version' => 0,
|
|
]);
|
|
|
|
$childLayout = TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'child-page',
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'child-page',
|
|
'extends' => '_base',
|
|
'slots' => [
|
|
'main' => [
|
|
['type' => 'basic', 'name' => 'OriginalContent'],
|
|
],
|
|
],
|
|
],
|
|
'lock_version' => 0,
|
|
]);
|
|
|
|
// 편집기 응답 시뮬레이션 — 머지된 components + __editor.original 컨테이너 동봉
|
|
$payload = [
|
|
'expected_lock_version' => 0,
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'child-page',
|
|
'components' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Slot',
|
|
'_fromBase' => true,
|
|
'__source' => ['kind' => 'base', 'layout' => '_base'],
|
|
'children' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'EditedContent',
|
|
'__source' => ['kind' => 'route', 'layout' => 'child-page'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'lock_version' => 0,
|
|
'__editor' => [
|
|
'original' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'child-page',
|
|
'extends' => '_base',
|
|
'slots' => [
|
|
'main' => [
|
|
['type' => 'basic', 'name' => 'EditedContent'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$childLayout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$childLayout->refresh();
|
|
// 자식 레이아웃 원본 형식으로 저장되어야 한다 — extends + slots
|
|
$this->assertEquals('_base', $childLayout->content['extends']);
|
|
$this->assertArrayHasKey('slots', $childLayout->content);
|
|
$this->assertEquals('EditedContent', $childLayout->content['slots']['main'][0]['name']);
|
|
// 머지된 components 는 자식에 박히지 않아야 한다
|
|
$this->assertArrayNotHasKey('components', $childLayout->content);
|
|
// 응답 전용 메타 모두 제거
|
|
$this->assertArrayNotHasKey('lock_version', $childLayout->content);
|
|
$this->assertArrayNotHasKey('__editor', $childLayout->content);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_drops_response_only_lock_version_meta_from_content(): void
|
|
{
|
|
$layout = $this->makeLayout(lockVersion: 0);
|
|
|
|
$payload = [
|
|
'expected_lock_version' => 0,
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'editor-target',
|
|
'endpoint' => '/api/admin/test',
|
|
// 응답 전용 메타 — 페이로드에 잘못 포함된 케이스
|
|
'lock_version' => 42,
|
|
'components' => [
|
|
[
|
|
'id' => 'route-main',
|
|
'name' => 'Main',
|
|
'type' => 'basic',
|
|
],
|
|
],
|
|
'data_sources' => [],
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layouts/{$layout->name}",
|
|
$payload,
|
|
);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$layout->refresh();
|
|
// content 안의 lock_version 메타는 저장되지 않아야 한다
|
|
$this->assertArrayNotHasKey('lock_version', $layout->content);
|
|
// 실제 lock_version 은 DB 컬럼 기반으로 1 증가
|
|
$this->assertEquals(1, $layout->lock_version);
|
|
}
|
|
|
|
#[Test]
|
|
public function layout_extension_update_shares_same_concurrency_guard(): void
|
|
{
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => 'extension_point',
|
|
'target_name' => 'header',
|
|
'source_type' => 'template',
|
|
'source_identifier' => $this->template->identifier,
|
|
'content' => [
|
|
'extension_point' => 'header',
|
|
'components' => [],
|
|
],
|
|
'lock_version' => 4,
|
|
]);
|
|
|
|
// stale expected → 409
|
|
$stale = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}",
|
|
[
|
|
'expected_lock_version' => 2,
|
|
'content' => [
|
|
'extension_point' => 'header',
|
|
'components' => [['type' => 'basic', 'name' => 'Span']],
|
|
],
|
|
],
|
|
);
|
|
$stale->assertStatus(409);
|
|
$this->assertEquals('concurrent_modification', $stale->json('errors.error'));
|
|
|
|
// match → 200, lock_version 증가
|
|
$ok = $this->authRequest()->putJson(
|
|
"/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}",
|
|
[
|
|
'expected_lock_version' => 4,
|
|
'content' => [
|
|
'extension_point' => 'header',
|
|
'components' => [['type' => 'basic', 'name' => 'Span']],
|
|
],
|
|
],
|
|
);
|
|
$ok->assertStatus(200);
|
|
|
|
$extension->refresh();
|
|
$this->assertEquals(5, $extension->lock_version);
|
|
}
|
|
}
|