*/ protected array $requiredExtensions = [ 'plugins/sirsoft-gdpr', ]; 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(); // 관리자 사용자 생성 (필요한 권한 포함) // PUT/POST 엔드포인트는 core.templates.layouts.edit 권한 필요 $this->adminUser = $this->createAdminUser([ 'core.templates.read', 'core.templates.activate', 'core.templates.layouts.edit', ]); $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', ]); } /** * 전체 버전 히스토리 플로우 통합 테스트 * * 시나리오 (PUT 당 버전 1건 — 저장 시점 content 스냅샷): * 1. 레이아웃 생성 * 2. 레이아웃 수정 (버전 1 = 첫 저장본 스냅샷) * 3. 다시 수정 (버전 2 = 두 번째 저장본 스냅샷) * 4. 버전 목록 조회 (2건) * 5. 특정 버전 조회 (버전 1 = 첫 저장본) * 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 생성) — factory lock_version=0 시작 $updateData1 = [ 'expected_lock_version' => 0, '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->identifier}/layouts/{$layout->name}", $updateData1); $response->assertStatus(200); $responseData = $response->json('data'); $this->assertCount(2, $responseData['components']); // 첫 수정 시: v1 = 수정 전 원본 baseline + v2 = 첫 수정본 → 2건. $this->assertEquals(2, TemplateLayoutVersion::where('layout_id', $layout->id)->count()); // 버전 1 = 수정 전 원본 baseline (header-1 만, metadata status:initial). 비교 대상 없어 변경 요약 0. $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->assertEquals(['status' => 'initial'], $version1->content['metadata']); $this->assertEquals(0, $version1->changes_summary['char_diff']); // 버전 2 = 첫 수정본(updateData1) — header-1 + footer-1, 원본 대비 변경 요약 0 아님. $version2first = TemplateLayoutVersion::where('layout_id', $layout->id)->where('version', 2)->first(); $this->assertNotNull($version2first); $this->assertCount(2, $version2first->content['components']); $this->assertEqualsCanonicalizing( ['header-1', 'footer-1'], array_column($version2first->content['components'], 'id') ); $this->assertNotEquals(0, $version2first->changes_summary['char_diff']); // 3. 레이아웃 두 번째 수정 (버전 2 생성) — 첫 저장으로 lock_version=1 $updateData2 = [ 'expected_lock_version' => 1, '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->identifier}/layouts/{$layout->name}", $updateData2); $response->assertStatus(200); // baseline(v1) + 첫 수정본(v2) + 두 번째 수정본(v3) = 3건. $this->assertEquals(3, TemplateLayoutVersion::where('layout_id', $layout->id)->count()); // 최신 버전(버전 3)의 changes_summary 는 직전(버전 2) 대비 변경을 정확히 담는다 — // sidebar-1 추가 + 변경 → char_diff 0 아님 (종전 결함: 최신 버전이 자기 비교로 항상 0). $version3 = TemplateLayoutVersion::where('layout_id', $layout->id)->where('version', 3)->first(); $this->assertNotNull($version3); $this->assertNotEquals(0, $version3->changes_summary['char_diff']); // sidebar-1 추가 → 새 라인 발생 (added 카운트 > 0) $this->assertGreaterThan(0, $version3->changes_summary['added']); // 4. 버전 목록 조회 — baseline + 2회 수정 = 3건 $response = $this->authRequest() ->getJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}/versions"); $response->assertStatus(200); $this->assertCount(3, $response->json('data')); // 5. 특정 버전 조회 (버전 1 = 수정 전 원본 baseline) $response = $this->authRequest() ->getJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}/versions/1"); $response->assertStatus(200); $versionData = $response->json('data'); $this->assertEquals(1, (int) $versionData['version']); // 버전 1 = 수정 전 원본 (header-1 만). $this->assertCount(1, $versionData['components']); $this->assertEquals('header-1', $versionData['components'][0]['id']); // 6. 버전 1로 복원 $response = $this->authRequest() ->postJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}/versions/{$version1->id}/restore"); $response->assertStatus(200); // 7. 복원은 saveVersion 1회 호출 (복원 전 content 저장). 3 + 1 = 4 버전 $this->assertEquals(4, TemplateLayoutVersion::where('layout_id', $layout->id)->count()); // 레이아웃의 현재 content가 버전 1(수정 전 원본 = header-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'): 버전 저장됨 (admin/user 구분 없이 모든 템플릿 편집 가능) */ public function test_version_saved_for_all_template_types(): 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'])] ), ]); // 유저 템플릿 레이아웃 수정 (factory lock_version=0) $this->authRequest() ->putJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$userLayout->name}", [ 'expected_lock_version' => 0, 'content' => $this->makeLayoutContent( 'user-layout', '/api/admin/user', [$this->makeComponent('test-1', 'TestComponent', ['value' => 'updated'])] ), ]) ->assertStatus(200); // 관리자 템플릿 레이아웃 수정 (factory lock_version=0) $this->authRequest() ->putJson("/api/admin/templates/{$this->adminTemplate->identifier}/layouts/{$adminLayout->name}", [ 'expected_lock_version' => 0, 'content' => $this->makeLayoutContent( 'admin-layout', '/api/admin/dashboard', [$this->makeComponent('test-1', 'TestComponent', ['value' => 'updated'])] ), ]) ->assertStatus(200); // 유저 템플릿: 첫 수정 → baseline(v1) + 수정본(v2) = 2건 $this->assertEquals(2, TemplateLayoutVersion::where('layout_id', $userLayout->id)->count()); // 관리자 템플릿도 동일하게 버전이 생성됨 (admin/user 구분 없이 편집 가능) $this->assertEquals(2, 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->identifier}/layouts/{$layout->name}", [ 'expected_lock_version' => 0, 'content' => $this->makeLayoutContent( 'large-layout', '/api/admin/large', $updatedComponents ), ]); $endTime = microtime(true); $executionTime = $endTime - $startTime; // 성공 확인 $response->assertStatus(200); // 버전이 생성되었는지 확인 (첫 수정 = baseline + 수정본 = 2 버전) $this->assertEquals(2, TemplateLayoutVersion::where('layout_id', $layout->id)->count()); // 성능 검증: 5초 이내 완료 (Windows + MySQL + RefreshDatabase 환경 고려한 여유값) $this->assertLessThan(5.0, $executionTime, "Large JSON diff took {$executionTime}s, expected < 5.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', ] ), ]); // 복잡한 변경 수행 (factory lock_version=0) $response = $this->authRequest() ->putJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}", [ 'expected_lock_version' => 0, '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); // 생성된 버전 확인 — 버전 2(수정본)의 changes_summary 검증 (v1 은 baseline, 변경 요약 0). $version = TemplateLayoutVersion::where('layout_id', $layout->id)->where('version', 2)->first(); $this->assertNotNull($version); $summary = $version->changes_summary; $this->assertNotNull($summary); $this->assertIsArray($summary); // changes_summary 구조 검증 — 라인 단위 카운트(added/removed 정수) + char_diff. // modified 는 라인 diff 에 대응 개념이 없어 두지 않는다. $this->assertArrayHasKey('added', $summary); $this->assertArrayHasKey('removed', $summary); $this->assertArrayHasKey('char_diff', $summary); $this->assertArrayNotHasKey('modified', $summary); $this->assertIsInt($summary['added']); $this->assertIsInt($summary['removed']); // sidebar 컴포넌트 추가 + header title/metadata 수정 → 추가/삭제 라인이 발생한다. // (값 변경은 라인 삭제+추가로 표현되므로 added/removed 모두 양수) $this->assertGreaterThan(0, $summary['added'], '추가/수정으로 새 라인이 생긴다'); $this->assertGreaterThan(0, $summary['removed'], '값 수정으로 옛 라인이 삭제된다'); $this->assertNotEquals(0, $summary['char_diff']); } /** * 동시 버전 저장 시 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'])] ), ]); // 순차 수정 — 매 저장으로 lock_version 이 1씩 증가하므로 직전 값을 전달 (i-1: 0,1,2,3,4). $promises = []; for ($i = 1; $i <= 5; $i++) { $response = $this->authRequest() ->putJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}", [ 'expected_lock_version' => $i - 1, '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); } // 버전 번호가 올바르게 증가했는지 확인 // (첫 PUT = baseline + 수정본 = 2건, 이후 4회 PUT = 각 1건 → 2 + 4 = 6 버전) $versions = TemplateLayoutVersion::where('layout_id', $layout->id) ->orderBy('version') ->pluck('version') ->toArray(); $this->assertCount(6, $versions); $this->assertEquals( range(1, 6), $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, ]); // 수정 (factory lock_version=0) — v1=원본 baseline, v2=수정본 생성 $this->authRequest() ->putJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}", [ 'expected_lock_version' => 0, '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->identifier}/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', [], [], [] ), ]); // 빈 배열에서 데이터 추가 (factory lock_version=0) $response = $this->authRequest() ->putJson("/api/admin/templates/{$this->userTemplate->identifier}/layouts/{$layout->name}", [ 'expected_lock_version' => 0, 'content' => $this->makeLayoutContent( 'empty-test', '/api/admin/empty', [$this->makeComponent('header', 'Header', ['value' => 'new'])], [$this->makeDataSource('api', '/api/test')], ['key' => 'value'] ), ]); $response->assertStatus(200); // 버전 1 = 빈 원본 baseline — 빈 배열이 올바르게 저장되었는지 확인 $version = TemplateLayoutVersion::where('layout_id', $layout->id)->where('version', 1)->first(); $this->assertNotNull($version); $this->assertEquals([], $version->content['components']); $this->assertEquals([], $version->content['data_sources']); $this->assertEquals([], $version->content['metadata']); // 추가된 데이터의 변경 요약은 버전 2(수정본)에 기록된다. $version = TemplateLayoutVersion::where('layout_id', $layout->id)->where('version', 2)->first(); $this->assertNotNull($version); // changes_summary 에서 추가 라인 수 확인 — 컴포넌트 + 데이터소스 + 메타데이터가 // 통째로 추가되었으므로 여러 라인이 added 로 집계된다(카운트 정수). $summary = $version->changes_summary; $this->assertIsInt($summary['added']); $this->assertGreaterThan(0, $summary['added'], '컴포넌트/데이터소스/메타데이터 추가로 라인이 늘어난다'); } }