462 lines
18 KiB
PHP
462 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Enums\LayoutExtensionType;
|
|
use App\Enums\LayoutSourceType;
|
|
use App\Extension\Traits\ComputesLayoutContentHash;
|
|
use App\Models\LayoutExtension;
|
|
use App\Models\Template;
|
|
use App\Models\TemplateLayout;
|
|
use App\Models\TemplateLayoutExtensionVersion;
|
|
use App\Services\LayoutExtensionService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* LayoutExtensionService 편집/버전관리 메서드 테스트
|
|
*
|
|
* 관리자 레이아웃 확장 편집 기능 (updateExtension, hasModifiedExtensions,
|
|
* registerExtension preserveModified) 을 검증합니다.
|
|
*/
|
|
class LayoutExtensionServiceEditingTest extends TestCase
|
|
{
|
|
use ComputesLayoutContentHash;
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 같은 스위트의 레이아웃/GDPR 미들웨어 의존 테스트와 migrate:fresh 정합성을
|
|
* 맞추기 위해 GDPR 플러그인 마이그레이션을 일관 선언한다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
protected array $requiredExtensions = [
|
|
'plugins/sirsoft-gdpr',
|
|
];
|
|
|
|
private LayoutExtensionService $service;
|
|
|
|
private Template $template;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->service = app(LayoutExtensionService::class);
|
|
$this->template = Template::factory()->create([
|
|
'identifier' => 'test-admin',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* updateExtension - content 갱신 + 버전 2건 생성
|
|
*/
|
|
public function test_update_extension_saves_content_and_creates_two_versions(): void
|
|
{
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'content' => ['extension_point' => 'header', 'components' => []],
|
|
]);
|
|
|
|
$newContent = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
|
|
|
$updated = $this->service->updateExtension($extension->id, ['content' => $newContent]);
|
|
|
|
$this->assertEquals($newContent, $updated->content);
|
|
// 이전 버전 + 현재 버전 = 2건
|
|
$this->assertEquals(2, TemplateLayoutExtensionVersion::where('extension_id', $extension->id)->count());
|
|
}
|
|
|
|
/**
|
|
* updateExtension - original_content_hash 는 불변
|
|
*/
|
|
public function test_update_extension_keeps_original_content_hash(): void
|
|
{
|
|
$originalContent = ['extension_point' => 'header', 'components' => []];
|
|
$originalHash = $this->computeContentHash($originalContent);
|
|
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'content' => $originalContent,
|
|
'original_content_hash' => $originalHash,
|
|
'original_content_size' => $this->computeContentSize($originalContent),
|
|
]);
|
|
|
|
$this->service->updateExtension($extension->id, [
|
|
'content' => ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Div']]],
|
|
]);
|
|
|
|
$extension->refresh();
|
|
// 수정 감지를 위해 원본 해시는 유지되어야 함
|
|
$this->assertEquals($originalHash, $extension->original_content_hash);
|
|
}
|
|
|
|
/**
|
|
* hasModifiedExtensions - 사용자 수정 확장 감지
|
|
*/
|
|
public function test_has_modified_extensions_detects_user_edits(): void
|
|
{
|
|
// 미수정 확장 (현재 content 해시 = original)
|
|
$unmodifiedContent = ['extension_point' => 'sidebar', 'components' => []];
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'content' => $unmodifiedContent,
|
|
'original_content_hash' => $this->computeContentHash($unmodifiedContent),
|
|
]);
|
|
|
|
// 수정된 확장 (현재 content 가 original 과 다름)
|
|
$modified = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'content' => ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]],
|
|
'original_content_hash' => $this->computeContentHash(['extension_point' => 'header', 'components' => []]),
|
|
]);
|
|
|
|
$result = $this->service->hasModifiedExtensions($this->template->id);
|
|
|
|
$this->assertCount(1, $result);
|
|
$this->assertEquals($modified->id, $result[0]['id']);
|
|
}
|
|
|
|
/**
|
|
* registerExtension preserveModified=true - 사용자 수정 확장은 SKIP
|
|
*/
|
|
public function test_register_extension_preserve_modified_skips_user_edited(): void
|
|
{
|
|
// 모듈이 처음 만든 원본
|
|
$originalContent = ['extension_point' => 'header', 'priority' => 100, 'components' => []];
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'header',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-test',
|
|
'content' => $originalContent,
|
|
'original_content_hash' => $this->computeContentHash($originalContent),
|
|
]);
|
|
|
|
// 사용자가 편집 (현재 content 변경 → 해시 불일치 발생)
|
|
$extension->update(['content' => ['extension_point' => 'header', 'priority' => 100, 'components' => [['type' => 'basic', 'name' => 'Span']]]]);
|
|
|
|
// 모듈 업데이트 시 새 파일 content 로 register (preserveModified=true)
|
|
$newFileContent = ['extension_point' => 'header', 'priority' => 200, 'components' => [['type' => 'basic', 'name' => 'Button']]];
|
|
$result = $this->service->registerExtension(
|
|
$newFileContent,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-test',
|
|
$this->template->id,
|
|
true
|
|
);
|
|
|
|
$this->assertEquals('skipped', $result);
|
|
|
|
// 사용자 수정 content 가 보존되어야 함 (덮어쓰기 안 됨)
|
|
$extension->refresh();
|
|
$this->assertEquals(
|
|
[['type' => 'basic', 'name' => 'Span']],
|
|
$extension->content['components']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* registerExtension preserveModified=true - 미수정 확장은 덮어쓰기 + 해시 갱신
|
|
*/
|
|
public function test_register_extension_preserve_modified_overwrites_unmodified(): void
|
|
{
|
|
$originalContent = ['extension_point' => 'header', 'priority' => 100, 'components' => []];
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'header',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-test',
|
|
'content' => $originalContent,
|
|
'original_content_hash' => $this->computeContentHash($originalContent),
|
|
]);
|
|
|
|
// 사용자 수정 없음 → 모듈 업데이트가 덮어씀
|
|
$newFileContent = ['extension_point' => 'header', 'priority' => 200, 'components' => [['type' => 'basic', 'name' => 'Button']]];
|
|
$result = $this->service->registerExtension(
|
|
$newFileContent,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-test',
|
|
$this->template->id,
|
|
true
|
|
);
|
|
|
|
$this->assertEquals('updated', $result);
|
|
|
|
$extension->refresh();
|
|
$this->assertEquals($newFileContent, $extension->content);
|
|
// original_content_hash 도 새 파일 기준으로 갱신
|
|
$this->assertEquals($this->computeContentHash($newFileContent), $extension->original_content_hash);
|
|
}
|
|
|
|
/**
|
|
* registerExtension - 신규 생성 시 original_content_hash 함께 저장
|
|
*/
|
|
public function test_register_extension_creates_with_original_hash(): void
|
|
{
|
|
$content = ['target_layout' => 'admin/dashboard', 'injections' => []];
|
|
|
|
$result = $this->service->registerExtension(
|
|
$content,
|
|
LayoutSourceType::Plugin,
|
|
'sirsoft-analytics',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertEquals('created', $result);
|
|
|
|
$extension = LayoutExtension::where('template_id', $this->template->id)
|
|
->where('source_identifier', 'sirsoft-analytics')
|
|
->first();
|
|
|
|
$this->assertNotNull($extension);
|
|
$this->assertEquals($this->computeContentHash($content), $extension->original_content_hash);
|
|
}
|
|
|
|
/**
|
|
* getExtensionsByTemplateId - 출처별 그룹핑
|
|
*/
|
|
public function test_get_extensions_grouped_by_source(): void
|
|
{
|
|
LayoutExtension::factory()->count(2)->create([
|
|
'template_id' => $this->template->id,
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'module-a',
|
|
]);
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'plugin-b',
|
|
]);
|
|
|
|
$groups = $this->service->getExtensionsByTemplateId($this->template->id);
|
|
|
|
$this->assertCount(2, $groups);
|
|
$sourceIds = array_column($groups, 'source_identifier');
|
|
$this->assertContains('module-a', $sourceIds);
|
|
$this->assertContains('plugin-b', $sourceIds);
|
|
}
|
|
|
|
/**
|
|
* getExtensionsByTemplateId - 템플릿 오버라이드 행에 is_override 플래그 부착
|
|
*
|
|
* 오버라이드 행은 트리에서 일반 확장과 동일하게 출처 기준으로 묶이되,
|
|
* is_override=true 로 오버라이드 상황이 별도 표시되어야 한다.
|
|
*/
|
|
public function test_get_extensions_marks_override_rows(): void
|
|
{
|
|
// 일반 플러그인 확장
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin/dashboard',
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'plugin-b',
|
|
'is_active' => true,
|
|
]);
|
|
// 템플릿 오버라이드 행
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'address_search_slot',
|
|
'source_type' => LayoutSourceType::Template,
|
|
'source_identifier' => $this->template->identifier,
|
|
'override_target' => 'some-plugin',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$groups = $this->service->getExtensionsByTemplateId($this->template->id);
|
|
|
|
$allExtensions = collect($groups)->flatMap(fn ($g) => $g['extensions']);
|
|
$override = $allExtensions->firstWhere('target_name', 'address_search_slot');
|
|
$normal = $allExtensions->firstWhere('target_name', 'admin/dashboard');
|
|
|
|
$this->assertNotNull($override);
|
|
$this->assertTrue($override->is_override, '오버라이드 행에 is_override 플래그 누락');
|
|
$this->assertNotNull($normal);
|
|
$this->assertFalse($normal->is_override, '일반 확장에 is_override 가 true 로 설정됨');
|
|
}
|
|
|
|
/**
|
|
* isExtensionApplicableToTemplate - overlay: 대상 레이아웃 존재 시 true
|
|
*/
|
|
public function test_overlay_applicable_when_target_layout_exists(): void
|
|
{
|
|
TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'admin_user_detail',
|
|
]);
|
|
|
|
$content = ['target_layout' => 'admin_user_detail', 'injections' => []];
|
|
|
|
$this->assertTrue($this->service->isExtensionApplicableToTemplate($content, $this->template->id));
|
|
}
|
|
|
|
/**
|
|
* isExtensionApplicableToTemplate - overlay: 대상 레이아웃 부재 시 false
|
|
*/
|
|
public function test_overlay_not_applicable_when_target_layout_missing(): void
|
|
{
|
|
$content = ['target_layout' => 'admin_user_detail', 'injections' => []];
|
|
|
|
// 해당 템플릿에 admin_user_detail 레이아웃이 없음
|
|
$this->assertFalse($this->service->isExtensionApplicableToTemplate($content, $this->template->id));
|
|
}
|
|
|
|
/**
|
|
* isExtensionApplicableToTemplate - extension_point: 확장점 정의 존재 시 true
|
|
*/
|
|
public function test_extension_point_applicable_when_defined_in_layout(): void
|
|
{
|
|
TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'dashboard',
|
|
'components' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'children' => [
|
|
['type' => 'extension_point', 'name' => 'admin.dashboard.widgets'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$content = ['extension_point' => 'admin.dashboard.widgets', 'components' => []];
|
|
|
|
$this->assertTrue($this->service->isExtensionApplicableToTemplate($content, $this->template->id));
|
|
}
|
|
|
|
/**
|
|
* isExtensionApplicableToTemplate - extension_point: 정의 부재 시 false
|
|
*/
|
|
public function test_extension_point_not_applicable_when_undefined(): void
|
|
{
|
|
TemplateLayout::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'version' => '1.0.0',
|
|
'layout_name' => 'dashboard',
|
|
'components' => [['type' => 'basic', 'name' => 'Div']],
|
|
],
|
|
]);
|
|
|
|
$content = ['extension_point' => 'admin.dashboard.widgets', 'components' => []];
|
|
|
|
$this->assertFalse($this->service->isExtensionApplicableToTemplate($content, $this->template->id));
|
|
}
|
|
|
|
/**
|
|
* removeInapplicableExtension - 동일 확장이 다른 템플릿엔 적용 가능할 때만 오등록 삭제.
|
|
*
|
|
* cross-template 가드: "이 템플릿엔 부재" 만으로 삭제하지 않는다. 같은 4키 확장이
|
|
* 다른 템플릿(여기선 user, 대상 레이아웃 존재)엔 적용 가능하면 이 행이 오등록 → 삭제.
|
|
*/
|
|
public function test_remove_inapplicable_extension_soft_deletes_orphan_row(): void
|
|
{
|
|
// user 템플릿엔 admin_user_detail 레이아웃 존재 + user 행 (= 다른 템플릿엔 적용 가능)
|
|
$userTemplate = Template::factory()->create(['type' => 'user']);
|
|
TemplateLayout::factory()->create([
|
|
'template_id' => $userTemplate->id,
|
|
'name' => 'admin_user_detail',
|
|
]);
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $userTemplate->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-board',
|
|
]);
|
|
|
|
// this->template(admin)엔 admin_user_detail 레이아웃 없음 → 오등록
|
|
$orphan = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-board',
|
|
]);
|
|
|
|
$content = ['target_layout' => 'admin_user_detail', 'injections' => []];
|
|
|
|
$removed = $this->service->removeInapplicableExtension(
|
|
$content,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-board',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertTrue($removed);
|
|
$this->assertSoftDeleted('template_layout_extensions', ['id' => $orphan->id]);
|
|
}
|
|
|
|
/**
|
|
* removeInapplicableExtension - 모든 템플릿에서 부재(전부 stale)면 삭제하지 않는다.
|
|
*
|
|
* cross-template 가드 핵심: 호스트 레이아웃이 전반적으로 구버전(슬롯 부재)이라 모든
|
|
* 등록 템플릿에서 동시에 "부재" 가 될 때, 이는 오등록이 아니므로 정상 확장을 보존한다
|
|
* (관리자 대시보드 위젯 소실 회귀 차단).
|
|
*/
|
|
public function test_remove_inapplicable_extension_preserves_when_all_templates_stale(): void
|
|
{
|
|
// admin·user 양쪽에 같은 확장 등록 + 어느 템플릿에도 대상 레이아웃 없음(전부 부재)
|
|
$userTemplate = Template::factory()->create(['type' => 'user']);
|
|
$adminRow = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'admin_dashboard_commerce',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-ecommerce',
|
|
]);
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $userTemplate->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'admin_dashboard_commerce',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-ecommerce',
|
|
]);
|
|
|
|
$content = ['extension_point' => 'admin_dashboard_commerce'];
|
|
|
|
$removed = $this->service->removeInapplicableExtension(
|
|
$content,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-ecommerce',
|
|
$this->template->id
|
|
);
|
|
|
|
// 다른 템플릿에서도 적용 불가(전부 stale) → 삭제 보류
|
|
$this->assertFalse($removed);
|
|
$this->assertNotSoftDeleted('template_layout_extensions', ['id' => $adminRow->id]);
|
|
}
|
|
|
|
/**
|
|
* removeInapplicableExtension - 해당 행이 없으면 false
|
|
*/
|
|
public function test_remove_inapplicable_extension_returns_false_when_no_row(): void
|
|
{
|
|
$content = ['target_layout' => 'admin_user_detail', 'injections' => []];
|
|
|
|
$removed = $this->service->removeInapplicableExtension(
|
|
$content,
|
|
LayoutSourceType::Module,
|
|
'sirsoft-board',
|
|
$this->template->id
|
|
);
|
|
|
|
$this->assertFalse($removed);
|
|
}
|
|
}
|