Files
Gnuboard7/tests/Unit/Services/LayoutResolverServiceTest.php
2026-04-20 20:37:49 +09:00

687 lines
25 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
use App\Models\Module;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Repositories\LayoutRepository;
use App\Services\LayoutResolverService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class LayoutResolverServiceTest extends TestCase
{
use RefreshDatabase;
private LayoutResolverService $resolverService;
private LayoutRepository $layoutRepository;
private Template $template;
protected function setUp(): void
{
parent::setUp();
$this->layoutRepository = app(LayoutRepository::class);
$this->resolverService = app(LayoutResolverService::class);
// 테스트용 템플릿 생성
$this->template = Template::create([
'identifier' => 'test-admin-template',
'vendor' => 'test',
'name' => ['ko' => '테스트 관리자 템플릿', 'en' => 'Test Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
}
/**
* 템플릿 오버라이드가 모듈 레이아웃보다 우선하는지 테스트
*/
public function test_resolve_prioritizes_template_override_over_module_layout(): void
{
$layoutName = 'sirsoft-sample_admin_index';
// 모듈 기본 레이아웃 생성
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-sample',
]);
// 템플릿 오버라이드 레이아웃 생성
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'OverrideComponent']]]],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
// 해석 실행
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
// 오버라이드가 반환되어야 함
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
$this->assertEquals(LayoutSourceType::Template, $resolved->source_type);
$this->assertStringContainsString('OverrideComponent', json_encode($resolved->content));
}
/**
* 오버라이드가 없을 때 모듈 레이아웃이 반환되는지 테스트
*/
public function test_resolve_returns_module_layout_when_no_override(): void
{
$layoutName = 'sirsoft-sample_admin_edit';
// 모듈 기본 레이아웃만 생성
$moduleLayout = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleEditComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-sample',
]);
// 해석 실행
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
// 모듈 레이아웃이 반환되어야 함
$this->assertNotNull($resolved);
$this->assertEquals($moduleLayout->id, $resolved->id);
$this->assertEquals(LayoutSourceType::Module, $resolved->source_type);
}
/**
* 레이아웃이 존재하지 않을 때 null 반환 테스트
*/
public function test_resolve_returns_null_when_layout_not_found(): void
{
$resolved = $this->resolverService->resolve('non-existent-layout', $this->template->id);
$this->assertNull($resolved);
}
/**
* 캐시 히트 테스트
*/
public function test_resolve_uses_cache_on_subsequent_calls(): void
{
$layoutName = 'cached-layout';
// 레이아웃 생성
$layout = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 캐시 통계 초기화
$this->resolverService->resetCacheStats();
// 첫 번째 호출 (캐시 미스)
$resolved1 = $this->resolverService->resolve($layoutName, $this->template->id);
$stats1 = $this->resolverService->getCacheStats();
$this->assertEquals(0, $stats1['hits']);
$this->assertEquals(1, $stats1['misses']);
// 두 번째 호출 (캐시 히트)
$resolved2 = $this->resolverService->resolve($layoutName, $this->template->id);
$stats2 = $this->resolverService->getCacheStats();
$this->assertEquals(1, $stats2['hits']);
$this->assertEquals(1, $stats2['misses']);
// 동일한 결과 반환
$this->assertEquals($resolved1->id, $resolved2->id);
}
/**
* 캐시 무효화 후 새 데이터 로드 테스트
*/
public function test_clear_resolution_cache_invalidates_cache(): void
{
$layoutName = 'invalidate-test-layout';
// 레이아웃 생성
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 첫 번째 호출로 캐싱
$this->resolverService->resolve($layoutName, $this->template->id);
// 캐시 무효화
$this->resolverService->clearResolutionCache($layoutName, $this->template->id);
// 캐시가 삭제되었는지 확인
$cacheKey = "layout_resolver.{$this->template->id}.{$layoutName}";
$this->assertNull(Cache::get($cacheKey));
}
/**
* 템플릿별 전체 캐시 무효화 테스트
*/
public function test_clear_all_resolution_cache_by_template(): void
{
// 여러 레이아웃 생성
for ($i = 1; $i <= 3; $i++) {
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "layout-{$i}",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 캐싱
$this->resolverService->resolve("layout-{$i}", $this->template->id);
}
// 전체 캐시 무효화
$this->resolverService->clearAllResolutionCacheByTemplate($this->template->id);
// 모든 캐시가 삭제되었는지 확인
for ($i = 1; $i <= 3; $i++) {
$cacheKey = "layout_resolver.{$this->template->id}.layout-{$i}";
$this->assertNull(Cache::get($cacheKey));
}
}
/**
* 모듈별 캐시 무효화 테스트
*/
public function test_clear_resolution_cache_by_module(): void
{
$moduleIdentifier = 'sirsoft-ecommerce';
// 모듈 레이아웃 생성
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "{$moduleIdentifier}_admin_products",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "{$moduleIdentifier}_admin_orders",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
// 캐싱
$this->resolverService->resolve("{$moduleIdentifier}_admin_products", $this->template->id);
$this->resolverService->resolve("{$moduleIdentifier}_admin_orders", $this->template->id);
// 모듈 캐시 무효화
$this->resolverService->clearResolutionCacheByModule($moduleIdentifier);
// 캐시가 삭제되었는지 확인
$cacheKey1 = "layout_resolver.{$this->template->id}.{$moduleIdentifier}_admin_products";
$cacheKey2 = "layout_resolver.{$this->template->id}.{$moduleIdentifier}_admin_orders";
$this->assertNull(Cache::get($cacheKey1));
$this->assertNull(Cache::get($cacheKey2));
}
/**
* 오버라이드 여부 확인 테스트
*/
public function test_is_overridden_returns_true_when_override_exists(): void
{
$layoutName = 'sirsoft-sample_admin_list';
// 모듈 레이아웃
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-sample',
]);
// 오버라이드 없음
$this->assertFalse($this->resolverService->isOverridden($layoutName, $this->template->id));
// 템플릿 오버라이드 추가
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
// 오버라이드 있음
$this->assertTrue($this->resolverService->isOverridden($layoutName, $this->template->id));
}
/**
* 오버라이드된 레이아웃 목록 조회 테스트
*/
public function test_get_overridden_layouts_returns_all_overrides(): void
{
// 모듈 레이아웃들
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => 'module_layout_1',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => 'module_layout_2',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 오버라이드 1개만 추가
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => 'module_layout_1',
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$overriddenLayouts = $this->resolverService->getOverriddenLayouts($this->template->id);
$this->assertCount(1, $overriddenLayouts);
$this->assertEquals('module_layout_1', $overriddenLayouts->first()->name);
}
/**
* 특정 모듈의 오버라이드된 레이아웃 조회 테스트
*/
public function test_get_module_layout_overrides(): void
{
$moduleIdentifier = 'sirsoft-test';
// 모듈 레이아웃 생성
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "{$moduleIdentifier}_layout_1",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "{$moduleIdentifier}_layout_2",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
// 오버라이드 추가
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => "{$moduleIdentifier}_layout_1",
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$moduleOverrides = $this->resolverService->getModuleLayoutOverrides($moduleIdentifier, $this->template->id);
$this->assertCount(1, $moduleOverrides);
$this->assertEquals("{$moduleIdentifier}_layout_1", $moduleOverrides->first()->name);
}
/**
* 캐시 통계 계산 테스트
*/
public function test_cache_stats_calculation(): void
{
$layoutName = 'stats-test-layout';
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 초기화
$this->resolverService->resetCacheStats();
// 5번 호출 (1 미스 + 4 히트)
for ($i = 0; $i < 5; $i++) {
$this->resolverService->resolve($layoutName, $this->template->id);
}
$stats = $this->resolverService->getCacheStats();
$this->assertEquals(4, $stats['hits']);
$this->assertEquals(1, $stats['misses']);
$this->assertEquals(5, $stats['total']);
$this->assertEquals(80.0, $stats['hit_rate']);
}
/**
* 캐시에 null 결과도 저장되는지 테스트
*/
public function test_resolve_caches_null_result(): void
{
$layoutName = 'non-existent-layout';
// 통계 초기화
$this->resolverService->resetCacheStats();
// 첫 번째 호출 (캐시 미스)
$result1 = $this->resolverService->resolve($layoutName, $this->template->id);
$this->assertNull($result1);
// 두 번째 호출 (캐시 히트 - null이 캐시됨)
$result2 = $this->resolverService->resolve($layoutName, $this->template->id);
$this->assertNull($result2);
$stats = $this->resolverService->getCacheStats();
$this->assertEquals(1, $stats['hits']);
$this->assertEquals(1, $stats['misses']);
}
/**
* version_constraint가 없는 오버라이드는 항상 적용됨
*/
public function test_override_without_version_constraint_is_always_applied(): void
{
$layoutName = 'sirsoft-sample.admin_index';
// 모듈 기본 레이아웃
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-sample',
]);
// version_constraint 없는 오버라이드
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'OverrideComponent']]]],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
}
/**
* version_constraint 호환 시 오버라이드 적용됨
*/
public function test_override_with_compatible_version_constraint_is_applied(): void
{
$layoutName = 'sirsoft-ver-test.admin_index';
$moduleIdentifier = 'sirsoft-ver-test';
// 모듈 레코드 생성 (버전 1.2.0)
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'sirsoft',
'name' => ['ko' => '버전 테스트', 'en' => 'Version Test'],
'version' => '1.2.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 모듈 기본 레이아웃
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
// 호환 버전 제약 오버라이드 (>=1.0.0)
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => [
'version_constraint' => '>=1.0.0',
'slots' => ['content' => [['component' => 'OverrideComponent']]],
],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
$this->assertEquals(LayoutSourceType::Template, $resolved->source_type);
}
/**
* version_constraint 비호환 시 모듈 기본 레이아웃으로 폴백
*/
public function test_override_with_incompatible_version_falls_back_to_module(): void
{
$layoutName = 'sirsoft-fallback.admin_index';
$moduleIdentifier = 'sirsoft-fallback';
// 모듈 레코드 생성 (버전 0.9.0)
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'sirsoft',
'name' => ['ko' => '폴백 테스트', 'en' => 'Fallback Test'],
'version' => '0.9.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 모듈 기본 레이아웃
$moduleLayout = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
// 비호환 버전 제약 오버라이드 (>=1.0.0 이지만 모듈은 0.9.0)
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => [
'version_constraint' => '>=1.0.0',
'slots' => ['content' => [['component' => 'OverrideComponent']]],
],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
// 모듈 기본 레이아웃으로 폴백
$this->assertNotNull($resolved);
$this->assertEquals($moduleLayout->id, $resolved->id);
$this->assertEquals(LayoutSourceType::Module, $resolved->source_type);
}
/**
* 모듈 버전 정보 없을 때 오버라이드 적용됨 (하위 호환)
*/
public function test_override_applied_when_module_version_unknown(): void
{
$layoutName = 'sirsoft-unknown.admin_index';
// 모듈 레코드 없음 (미설치)
// 모듈 기본 레이아웃
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-unknown',
]);
// version_constraint가 있지만 모듈 버전 정보 없음
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => [
'version_constraint' => '>=1.0.0',
'slots' => [],
],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
// 버전 정보 없으면 호환으로 처리
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
}
/**
* 잘못된 version_constraint 포맷 시 오버라이드 적용됨 (안전 폴백)
*/
public function test_override_applied_when_version_constraint_invalid(): void
{
$layoutName = 'sirsoft-invalid.admin_index';
$moduleIdentifier = 'sirsoft-invalid';
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'sirsoft',
'name' => ['ko' => '잘못된 제약', 'en' => 'Invalid Constraint'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => []],
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
// 잘못된 제약 조건
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => [
'version_constraint' => '>>>invalid<<<',
'slots' => [],
],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
// 파싱 실패 시 호환으로 처리 (안전 폴백)
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
}
/**
* DOT 포맷 layout_name으로 오버라이드 end-to-end 매칭 테스트
*/
public function test_dot_format_layout_name_override_matching(): void
{
$layoutName = 'sirsoft-sample.admin_index';
// 모듈 기본 레이아웃 (DOT 포맷)
TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'ModuleComponent']]]],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'sirsoft-sample',
]);
// 오버라이드 (DOT 포맷 매칭)
$override = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['slots' => ['content' => [['component' => 'OverrideComponent']]]],
'source_type' => LayoutSourceType::Template,
'source_identifier' => 'test-admin-template',
]);
$resolved = $this->resolverService->resolve($layoutName, $this->template->id);
$this->assertNotNull($resolved);
$this->assertEquals($override->id, $resolved->id);
}
/**
* 다중 템플릿 간 캐시 분리 테스트
*/
public function test_cache_separation_between_templates(): void
{
$layoutName = 'shared-layout';
// 두 번째 템플릿 생성
$template2 = Template::create([
'identifier' => 'second-template',
'vendor' => 'test',
'name' => ['ko' => '두 번째 템플릿', 'en' => 'Second Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 각 템플릿에 동일한 이름의 레이아웃 생성
$layout1 = TemplateLayout::create([
'template_id' => $this->template->id,
'name' => $layoutName,
'content' => ['version' => '1'],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
$layout2 = TemplateLayout::create([
'template_id' => $template2->id,
'name' => $layoutName,
'content' => ['version' => '2'],
'source_type' => LayoutSourceType::Module,
'source_identifier' => 'test-module',
]);
// 각 템플릿에서 해석
$resolved1 = $this->resolverService->resolve($layoutName, $this->template->id);
$resolved2 = $this->resolverService->resolve($layoutName, $template2->id);
// 서로 다른 레이아웃이 반환되어야 함
$this->assertNotEquals($resolved1->id, $resolved2->id);
$this->assertEquals('1', $resolved1->content['version']);
$this->assertEquals('2', $resolved2->content['version']);
}
}