Files
Gnuboard7/tests/Feature/Api/Admin/LayoutVersionIntegrationTest.php
T
2026-04-01 10:30:52 +09:00

729 lines
27 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\TemplateLayoutVersion;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 레이아웃 버전 히스토리 시스템 통합 테스트
*
* 전체 버전 히스토리 플로우 (생성 → 수정 → 버전 저장 → 조회 → 복원) 검증
* 유저 템플릿 전용 버전 저장 규칙 검증
* 대용량 JSON diff 성능 테스트
* changes_summary 정확도 검증
*/
class LayoutVersionIntegrationTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private Template $userTemplate;
private Template $adminTemplate;
private string $token;
/**
* 올바른 레이아웃 content 구조 생성 헬퍼
*/
private function makeLayoutContent(
string $layoutName,
string $endpoint,
array $components = [],
array $dataSources = [],
array $metadata = []
): array {
return [
'version' => '1.0.0',
'layout_name' => $layoutName,
'endpoint' => $endpoint,
'components' => $components,
'data_sources' => $dataSources,
'metadata' => $metadata,
];
}
/**
* 올바른 component 구조 생성 헬퍼
*/
private function makeComponent(
string $id,
string $name,
array $props = [],
string $type = 'basic',
array $children = []
): array {
$component = [
'id' => $id,
'type' => $type,
'name' => $name,
'props' => $props,
];
if (!empty($children)) {
$component['children'] = $children;
}
return $component;
}
/**
* 올바른 data_source 구조 생성 헬퍼
*/
private function makeDataSource(
string $id,
string $endpoint,
string $method = 'GET',
array $params = []
): array {
return [
'id' => $id,
'type' => 'api',
'endpoint' => $endpoint,
'method' => $method,
'params' => $params,
];
}
protected function setUp(): void
{
parent::setUp();
// 관리자 사용자 생성 (필요한 권한 포함)
$this->adminUser = $this->createAdminUser([
'core.templates.read',
'core.templates.activate',
]);
$this->token = $this->adminUser->createToken('test-token')->plainTextToken;
// 유저 템플릿과 관리자 템플릿 생성
$this->userTemplate = Template::factory()->create(['type' => 'user']);
$this->adminTemplate = Template::factory()->create(['type' => 'admin']);
}
/**
* 관리자 사용자 생성 (필요한 권한 포함)
* admin 미들웨어를 통과하기 위해 admin 역할도 함께 할당
*/
private function createAdminUser(array $permissions = []): User
{
$user = User::factory()->create();
// 권한 생성
$permissionIds = [];
foreach ($permissions as $permissionIdentifier) {
$permission = Permission::firstOrCreate(
['identifier' => $permissionIdentifier],
[
'name' => json_encode(['ko' => $permissionIdentifier, 'en' => $permissionIdentifier]),
'description' => json_encode(['ko' => $permissionIdentifier, 'en' => $permissionIdentifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
]
);
$permissionIds[] = $permission->id;
}
// 고유한 식별자로 역할 생성 (테스트별 격리를 위해)
$roleIdentifier = 'admin_test_'.uniqid();
$testRole = Role::create([
'identifier' => $roleIdentifier,
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
'is_active' => true,
]);
// admin 역할도 추가 (admin 미들웨어 통과용)
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
// 테스트용 역할에 권한 할당
$testRole->permissions()->sync($permissionIds);
// 사용자에게 admin 역할과 테스트용 역할 모두 할당
$user->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
$user->roles()->attach($testRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
return $user->fresh();
}
/**
* 인증된 요청 헬퍼 메서드
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* 전체 버전 히스토리 플로우 통합 테스트
*
* 시나리오:
* 1. 레이아웃 생성
* 2. 레이아웃 수정 (버전 1 자동 생성)
* 3. 다시 수정 (버전 2 자동 생성)
* 4. 버전 목록 조회
* 5. 특정 버전 조회
* 6. 버전 1로 복원
* 7. 복원 후 새 버전(3) 생성 확인
*/
public function test_full_version_history_workflow(): void
{
// 1. 레이아웃 생성
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'test-layout',
'content' => $this->makeLayoutContent(
'test-layout',
'/api/admin/test',
[$this->makeComponent('header-1', 'Header', ['title' => 'initial'])],
[],
['status' => 'initial']
),
]);
$this->assertDatabaseHas('template_layouts', [
'id' => $layout->id,
'name' => 'test-layout',
]);
// 초기 버전은 생성되지 않음
$this->assertEquals(0, TemplateLayoutVersion::where('layout_id', $layout->id)->count());
// 2. 레이아웃 첫 수정 (버전 1 생성)
$updateData1 = [
'content' => $this->makeLayoutContent(
'test-layout',
'/api/admin/test',
[
$this->makeComponent('header-1', 'Header', ['title' => 'updated_v1']),
$this->makeComponent('footer-1', 'Footer', ['text' => 'new']),
],
[],
['status' => 'updated_v1']
),
];
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", $updateData1);
$response->assertStatus(200);
$responseData = $response->json('data');
$this->assertCount(2, $responseData['components']);
// 버전 1이 생성되었는지 확인
$this->assertEquals(1, TemplateLayoutVersion::where('layout_id', $layout->id)->count());
$version1 = TemplateLayoutVersion::where('layout_id', $layout->id)
->where('version', 1)
->first();
$this->assertNotNull($version1);
$this->assertCount(1, $version1->content['components']);
$this->assertEquals('header-1', $version1->content['components'][0]['id']);
$this->assertNotNull($version1->changes_summary);
// 3. 레이아웃 두 번째 수정 (버전 2 생성)
$updateData2 = [
'content' => $this->makeLayoutContent(
'test-layout',
'/api/admin/test',
[
$this->makeComponent('header-1', 'Header', ['title' => 'updated_v2']),
$this->makeComponent('footer-1', 'Footer', ['text' => 'modified']),
$this->makeComponent('sidebar-1', 'Sidebar', ['position' => 'left']),
],
[],
['status' => 'updated_v2', 'author' => 'admin']
),
];
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", $updateData2);
$response->assertStatus(200);
// 버전 2가 생성되었는지 확인
$this->assertEquals(2, TemplateLayoutVersion::where('layout_id', $layout->id)->count());
// 4. 버전 목록 조회
$response = $this->authRequest()
->getJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}/versions");
$response->assertStatus(200);
$this->assertCount(2, $response->json('data'));
// 5. 특정 버전 조회 (버전 1)
$response = $this->authRequest()
->getJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}/versions/1");
$response->assertStatus(200);
$versionData = $response->json('data');
$this->assertEquals(1, (int) $versionData['version']);
$this->assertCount(1, $versionData['components']);
$this->assertEquals('header-1', $versionData['components'][0]['id']);
// 6. 버전 1로 복원
$response = $this->authRequest()
->postJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}/versions/{$version1->id}/restore");
$response->assertStatus(200);
// 7. 복원 후 새 버전(3) 생성 확인
$this->assertEquals(3, TemplateLayoutVersion::where('layout_id', $layout->id)->count());
$version3 = TemplateLayoutVersion::where('layout_id', $layout->id)
->where('version', 3)
->first();
$this->assertNotNull($version3);
$this->assertCount(3, $version3->content['components']);
// 레이아웃의 현재 content가 버전 1로 복원되었는지 확인
$layout->refresh();
$this->assertCount(1, $layout->content['components']);
$this->assertEquals('header-1', $layout->content['components'][0]['id']);
$this->assertEquals(['status' => 'initial'], $layout->content['metadata']);
}
/**
* 유저 템플릿만 버전이 저장되는지 검증
*
* - 유저 템플릿(type='user'): 버전 저장됨
* - 관리자 템플릿(type='admin'): 버전 저장되지 않음 (레이아웃 편집 불가)
*/
public function test_version_saved_only_for_user_templates(): void
{
// 유저 템플릿 레이아웃 생성
$userLayout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'user-layout',
'content' => $this->makeLayoutContent(
'user-layout',
'/api/admin/user',
[$this->makeComponent('test-1', 'TestComponent', ['value' => 'initial'])]
),
]);
// 관리자 템플릿 레이아웃 생성
$adminLayout = TemplateLayout::factory()->create([
'template_id' => $this->adminTemplate->id,
'name' => 'admin-layout',
'content' => $this->makeLayoutContent(
'admin-layout',
'/api/admin/dashboard',
[$this->makeComponent('test-1', 'TestComponent', ['value' => 'initial'])]
),
]);
// 유저 템플릿 레이아웃 수정
$this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$userLayout->name}", [
'content' => $this->makeLayoutContent(
'user-layout',
'/api/admin/user',
[$this->makeComponent('test-1', 'TestComponent', ['value' => 'updated'])]
),
])
->assertStatus(200);
// 관리자 템플릿 레이아웃 수정
$this->authRequest()
->putJson("/api/admin/templates/{$this->adminTemplate->id}/layouts/{$adminLayout->name}", [
'content' => $this->makeLayoutContent(
'admin-layout',
'/api/admin/dashboard',
[$this->makeComponent('test-1', 'TestComponent', ['value' => 'updated'])]
),
])
->assertStatus(200);
// 유저 템플릿은 버전이 생성됨
$this->assertEquals(1, TemplateLayoutVersion::where('layout_id', $userLayout->id)->count());
// 관리자 템플릿은 버전이 생성되지 않음 (레이아웃 편집 불가 정책)
$this->assertEquals(0, TemplateLayoutVersion::where('layout_id', $adminLayout->id)->count());
}
/**
* 대용량 JSON diff 성능 테스트
*
* 목표: 1MB 이상 JSON 처리 시 1초 이내 완료
*/
public function test_large_json_diff_performance(): void
{
// 대용량 components 생성 (약 1MB 이상)
$largeComponents = [];
for ($i = 0; $i < 1000; $i++) {
$largeComponents[] = $this->makeComponent(
"component_$i",
"Component $i",
[
'componentId' => $i,
'componentName' => "Component $i",
'description' => str_repeat("This is a long description for component $i. ", 10),
'config' => [
'option1' => 'value1',
'option2' => 'value2',
'option3' => 'value3',
'nested' => [
'deep1' => 'data1',
'deep2' => 'data2',
],
],
],
'composite'
);
}
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'large-layout',
'content' => $this->makeLayoutContent(
'large-layout',
'/api/admin/large',
$largeComponents
),
]);
// components 일부 수정
$updatedComponents = $largeComponents;
for ($i = 0; $i < 100; $i++) {
$updatedComponents[$i]['props']['description'] = "Updated description for component $i";
}
// 성능 측정 시작
$startTime = microtime(true);
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", [
'content' => $this->makeLayoutContent(
'large-layout',
'/api/admin/large',
$updatedComponents
),
]);
$endTime = microtime(true);
$executionTime = $endTime - $startTime;
// 성공 확인
$response->assertStatus(200);
// 버전이 생성되었는지 확인
$this->assertEquals(1, TemplateLayoutVersion::where('layout_id', $layout->id)->count());
// 성능 검증: 1초 이내 완료
$this->assertLessThan(1.0, $executionTime, "Large JSON diff took {$executionTime}s, expected < 1.0s");
// changes_summary가 생성되었는지 확인
$version = TemplateLayoutVersion::where('layout_id', $layout->id)->first();
$this->assertNotNull($version->changes_summary);
}
/**
* changes_summary 정확도 검증
*
* 실제 변경 사항과 changes_summary가 일치하는지 확인
*/
public function test_changes_summary_accuracy(): void
{
// 초기 레이아웃 생성
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'accuracy-test',
'content' => $this->makeLayoutContent(
'accuracy-test',
'/api/admin/accuracy',
[
$this->makeComponent('header', 'Header', ['title' => 'Original']),
$this->makeComponent('footer', 'Footer', ['text' => 'Original']),
],
[
$this->makeDataSource('api1', '/api/data1'),
],
[
'author' => 'admin',
'status' => 'draft',
]
),
]);
// 복잡한 변경 수행
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", [
'content' => $this->makeLayoutContent(
'accuracy-test',
'/api/admin/accuracy',
[
$this->makeComponent('header', 'Header', ['title' => 'Updated']), // 수정
$this->makeComponent('footer', 'Footer', ['text' => 'Original']), // 변경 없음
$this->makeComponent('sidebar', 'Sidebar', ['position' => 'left']), // 추가
],
[
$this->makeDataSource('api1', '/api/data1'), // 변경 없음
$this->makeDataSource('api2', '/api/data2'), // 추가
],
[
'author' => 'admin', // 변경 없음
'status' => 'published', // 수정
'version' => '2.0.0', // 추가
]
),
]);
$response->assertStatus(200);
// 생성된 버전 확인
$version = TemplateLayoutVersion::where('layout_id', $layout->id)->first();
$this->assertNotNull($version);
$summary = $version->changes_summary;
$this->assertNotNull($summary);
$this->assertIsArray($summary);
// changes_summary 구조 검증
$this->assertArrayHasKey('added', $summary);
$this->assertArrayHasKey('removed', $summary);
$this->assertArrayHasKey('modified', $summary);
// 3번째 컴포넌트 (sidebar)가 추가되었는지 확인
$addedPaths = $summary['added'];
$this->assertTrue(
collect($addedPaths)->contains(fn($path) => str_contains($path, 'components.2')),
'Sidebar component should be added at index 2'
);
// 1번째 컴포넌트 (header)의 title이 수정되었는지 확인
$modifiedPaths = $summary['modified'];
$this->assertTrue(
collect($modifiedPaths)->contains(fn($path) => str_contains($path, 'components.0.props.title')),
'Header title should be modified'
);
// metadata 변경 확인
$this->assertTrue(
collect($modifiedPaths)->contains(fn($path) => str_contains($path, 'metadata.status')),
'Metadata status should be modified'
);
$this->assertTrue(
collect($addedPaths)->contains(fn($path) => str_contains($path, 'metadata.version')),
'Metadata version should be added'
);
}
/**
* 동시 버전 저장 시 race condition 테스트
*/
public function test_concurrent_version_creation_race_condition(): void
{
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'concurrent-test',
'content' => $this->makeLayoutContent(
'concurrent-test',
'/api/admin/concurrent',
[$this->makeComponent('test-1', 'TestComponent', ['value' => 'initial'])]
),
]);
// 거의 동시에 여러 번 수정
$promises = [];
for ($i = 1; $i <= 5; $i++) {
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", [
'content' => $this->makeLayoutContent(
'concurrent-test',
'/api/admin/concurrent',
[$this->makeComponent('test-1', 'TestComponent', ['value' => "update_$i"])]
),
]);
$promises[] = $response;
}
// 모든 요청이 성공했는지 확인
foreach ($promises as $response) {
$response->assertStatus(200);
}
// 버전 번호가 올바르게 증가했는지 확인
$versions = TemplateLayoutVersion::where('layout_id', $layout->id)
->orderBy('version')
->pluck('version')
->toArray();
$expectedVersions = [1, 2, 3, 4, 5];
$this->assertEquals($expectedVersions, $versions, 'Version numbers should be sequential without gaps');
}
/**
* 복원 후 데이터 무결성 검증
*/
public function test_data_integrity_after_restore(): void
{
// 복잡한 초기 데이터
$initialContent = $this->makeLayoutContent(
'integrity-test',
'/api/admin/integrity',
[
$this->makeComponent('header', 'Header', [
'title' => 'Original Title',
'logo' => '/images/logo.png',
'navigation' => [
['label' => 'Home', 'url' => '/'],
['label' => 'About', 'url' => '/about'],
],
]),
],
[
$this->makeDataSource('user_api', '/api/users', 'GET'),
],
[
'author' => 'admin',
'created' => '2025-01-01',
'tags' => ['important', 'production'],
]
);
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'integrity-test',
'content' => $initialContent,
]);
// 수정 (버전 1 생성)
$this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", [
'content' => $this->makeLayoutContent(
'integrity-test',
'/api/admin/integrity',
[
$this->makeComponent('header', 'Header', [
'title' => 'Updated Title',
'logo' => '/images/new-logo.png',
]),
],
[
$this->makeDataSource('user_api', '/api/users', 'GET'),
],
[
'author' => 'admin',
'created' => '2025-01-01',
'tags' => ['important', 'production'],
]
),
]);
$version1 = TemplateLayoutVersion::where('layout_id', $layout->id)
->where('version', 1)
->first();
// 버전 1로 복원
$this->authRequest()
->postJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}/versions/{$version1->id}/restore");
// 복원된 데이터 검증
$layout->refresh();
// 중첩된 구조가 정확히 복원되었는지 확인 (components는 배열)
$this->assertCount(1, $layout->content['components']);
$this->assertEquals('header', $layout->content['components'][0]['id']);
$this->assertEquals('Original Title', $layout->content['components'][0]['props']['title']);
$this->assertEquals('/images/logo.png', $layout->content['components'][0]['props']['logo']);
$this->assertCount(2, $layout->content['components'][0]['props']['navigation']);
$this->assertEquals('Home', $layout->content['components'][0]['props']['navigation'][0]['label']);
// data_sources가 정확히 복원되었는지 확인 (배열)
$this->assertCount(1, $layout->content['data_sources']);
$this->assertEquals('user_api', $layout->content['data_sources'][0]['id']);
$this->assertEquals('/api/users', $layout->content['data_sources'][0]['endpoint']);
$this->assertEquals('GET', $layout->content['data_sources'][0]['method']);
// metadata가 정확히 복원되었는지 확인
$this->assertEquals(['important', 'production'], $layout->content['metadata']['tags']);
}
/**
* 빈 배열과 null 값 처리 검증
*/
public function test_handles_empty_arrays_and_null_values(): void
{
$layout = TemplateLayout::factory()->create([
'template_id' => $this->userTemplate->id,
'name' => 'empty-test',
'content' => $this->makeLayoutContent(
'empty-test',
'/api/admin/empty',
[],
[],
[]
),
]);
// 빈 배열에서 데이터 추가
$response = $this->authRequest()
->putJson("/api/admin/templates/{$this->userTemplate->id}/layouts/{$layout->name}", [
'content' => $this->makeLayoutContent(
'empty-test',
'/api/admin/empty',
[$this->makeComponent('header', 'Header', ['value' => 'new'])],
[$this->makeDataSource('api', '/api/test')],
['key' => 'value']
),
]);
$response->assertStatus(200);
// 버전이 생성되었는지 확인
$version = TemplateLayoutVersion::where('layout_id', $layout->id)->first();
$this->assertNotNull($version);
// 빈 배열이 올바르게 저장되었는지 확인
$this->assertEquals([], $version->content['components']);
$this->assertEquals([], $version->content['data_sources']);
$this->assertEquals([], $version->content['metadata']);
// changes_summary에서 추가 항목 확인
$summary = $version->changes_summary;
$addedPaths = $summary['added'];
$this->assertTrue(
collect($addedPaths)->contains(fn($path) => str_contains($path, 'components.0')),
'Header component should be added'
);
$this->assertTrue(
collect($addedPaths)->contains(fn($path) => str_contains($path, 'data_sources.0')),
'API data source should be added'
);
$this->assertTrue(
collect($addedPaths)->contains(fn($path) => str_contains($path, 'metadata.key')),
'Metadata key should be added'
);
}
}