관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다. 같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다. 근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서 관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을 정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면 정상이라 오류도 경고도 없이 페이로드만 불어난다. 목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB 집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의 limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다. 뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와 무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다. 배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어 제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다. 재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
849 lines
34 KiB
PHP
849 lines
34 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Enums\LayoutExtensionType;
|
|
use App\Enums\LayoutSourceType;
|
|
use App\Models\LayoutExtension;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\Template;
|
|
use App\Models\TemplateLayout;
|
|
use App\Models\TemplateLayoutExtensionVersion;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* LayoutExtensionController API 테스트
|
|
*
|
|
* 레이아웃 확장 편집·버전관리·미리보기 엔드포인트를 검증합니다.
|
|
*/
|
|
class LayoutExtensionControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 레이아웃/템플릿 요청 경로가 GDPR 정책 테이블을 조회하므로
|
|
* 해당 플러그인 마이그레이션을 테스트 DB 에 포함시킨다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 관리자 사용자 생성 (필요한 권한 포함)
|
|
*
|
|
* @param array<string> $permissions 부여할 권한 식별자 목록
|
|
*/
|
|
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',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
$testRole = Role::create([
|
|
'identifier' => 'admin_test_'.uniqid(),
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$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',
|
|
'type' => 'admin',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
$testRole->permissions()->sync($permissionIds);
|
|
$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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 extension_point 확장 생성
|
|
*/
|
|
private function makeExtensionPoint(array $overrides = []): LayoutExtension
|
|
{
|
|
return LayoutExtension::factory()->create(array_merge([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'header',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-test',
|
|
'content' => ['extension_point' => 'header', 'components' => []],
|
|
], $overrides));
|
|
}
|
|
|
|
/**
|
|
* index - 출처별 그룹핑 구조 반환
|
|
*/
|
|
public function test_index_returns_source_grouped_extensions(): 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',
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => [
|
|
'*' => ['source_identifier', 'source_type', 'source_label', 'extensions'],
|
|
],
|
|
]);
|
|
|
|
$this->assertCount(2, $response->json('data'));
|
|
}
|
|
|
|
/**
|
|
* index - 각 확장에 host_layouts 부착
|
|
*
|
|
* 라우트 트리가 클릭(캔버스 로드) 없이도 layoutName 매칭으로 화면별 연결 확장 목록을
|
|
* 정적 구성할 수 있도록, 목록 응답의 각 확장에 호스트 레이아웃 목록이 포함되어야 한다.
|
|
* overlay = [target_layout], extension_point = 그 확장점을 포함하는 레이아웃 전체.
|
|
*/
|
|
public function test_index_attaches_host_layouts_to_each_extension(): void
|
|
{
|
|
// overlay — 실재 target_id 노드에 편집 가능 components 를 주입 (modals/
|
|
// data_sources 전용·target_id 부재 overlay 는 시각 편집 호스트로 인정 안 됨).
|
|
TemplateLayout::create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'admin_user_detail',
|
|
'content' => ['components' => [['name' => 'Div', 'props' => ['id' => 'detail_panel']]]],
|
|
]);
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'module-overlay',
|
|
'content' => [
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [['target_id' => 'detail_panel', 'position' => 'append_child', 'components' => [['name' => 'Div']]]],
|
|
],
|
|
]);
|
|
|
|
// extension_point — 슬롯을 포함하는 레이아웃 2개 생성 후 그 슬롯 대상 확장 등록
|
|
TemplateLayout::create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'shop_checkout',
|
|
'content' => ['components' => [['type' => 'extension_point', 'name' => 'checkout.payment']]],
|
|
]);
|
|
TemplateLayout::create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'shop_reorder',
|
|
'content' => ['components' => [['type' => 'extension_point', 'name' => 'checkout.payment']]],
|
|
]);
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'checkout.payment',
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'plugin-pay',
|
|
// extension_point 가 편집 가능 컴포넌트를 실제로 주입해야 시각 편집 호스트로 인정.
|
|
'content' => ['extension_point' => 'checkout.payment', 'components' => [['name' => 'Div']]],
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$allExtensions = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions']);
|
|
|
|
$overlay = $allExtensions->firstWhere('target_name', 'admin_user_detail');
|
|
$this->assertNotNull($overlay);
|
|
$this->assertSame(['admin_user_detail'], $overlay['host_layouts']);
|
|
|
|
$ep = $allExtensions->firstWhere('target_name', 'checkout.payment');
|
|
$this->assertNotNull($ep);
|
|
$this->assertContains('shop_checkout', $ep['host_layouts']);
|
|
$this->assertContains('shop_reorder', $ep['host_layouts']);
|
|
}
|
|
|
|
/**
|
|
* index - 템플릿 오버라이드에 가려진 확장은 트리에서 제외됨
|
|
*
|
|
* 회귀: 편집 화면 좌측 트리가 화면에 실제 적용되지 않는 "가려진 행"을
|
|
* 노출하여, 관리자가 그 행을 편집해도 반영되지 않던 문제.
|
|
*/
|
|
public function test_index_excludes_overridden_extensions(): void
|
|
{
|
|
// 플러그인 Extension Point 원본
|
|
LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::ExtensionPoint,
|
|
'target_name' => 'address_search_slot',
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'sirsoft-daum_postcode',
|
|
'priority' => 100,
|
|
'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' => 'sirsoft-daum_postcode',
|
|
'priority' => 50,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
// 가려진 플러그인 원본(2건 등록)은 트리에서 제외되고 오버라이드 행 1건만 노출.
|
|
// 오버라이드 행은 is_override=true 로 표시된다.
|
|
$allExtensions = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions']);
|
|
$slotExtensions = $allExtensions->where('target_name', 'address_search_slot');
|
|
$this->assertCount(1, $slotExtensions, '오버라이드에 가려진 원본이 함께 노출됨');
|
|
$this->assertTrue($slotExtensions->first()['is_override'], '오버라이드 행에 is_override 플래그 누락');
|
|
$this->assertSame('template', $slotExtensions->first()['source_type']);
|
|
}
|
|
|
|
/**
|
|
* 확장 버전 목록도 본문을 싣지 않고 조회 건수 상한을 따른다.
|
|
*
|
|
* 레이아웃 본체와 같은 정책 — 버전 행은 저장할 때마다 쌓이고 정리되지 않는다.
|
|
*/
|
|
public function test_extension_version_list_omits_body_and_honors_limit(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
TemplateLayoutExtensionVersion::factory()->count(4)->sequence(
|
|
['version' => 1], ['version' => 2], ['version' => 3], ['version' => 4],
|
|
)->create(['extension_id' => $extension->id]);
|
|
|
|
$url = "/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions";
|
|
|
|
$response = $this->authRequest()->getJson($url);
|
|
$response->assertStatus(200);
|
|
|
|
foreach ($response->json('data') as $row) {
|
|
$this->assertArrayNotHasKey('content', $row, '버전 목록에 확장 본문이 실리면 안 된다');
|
|
$this->assertArrayHasKey('version', $row);
|
|
$this->assertArrayHasKey('changes_summary', $row);
|
|
}
|
|
|
|
// limit 지정 시 그 수만큼, 최신순
|
|
$limited = $this->authRequest()->getJson($url.'?limit=2');
|
|
$limited->assertStatus(200);
|
|
$this->assertCount(2, $limited->json('data'));
|
|
$this->assertSame([4, 3], array_column($limited->json('data'), 'version'));
|
|
|
|
// 상한을 넘는 limit 은 거부
|
|
$this->authRequest()->getJson($url.'?limit=100000')
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors('limit');
|
|
}
|
|
|
|
/**
|
|
* index - 확장 목록에는 편집 본문이 실리지 않는다.
|
|
*
|
|
* 목록은 라우트 트리에 "어느 화면에 어떤 확장이 붙어 있는가" 를 그리는 화면이다. 본문까지
|
|
* 함께 내려주면 템플릿에 등록된 모든 확장의 JSON 전문이 한 응답에 담긴다. 본문은 단건
|
|
* 조회가 공급한다.
|
|
*
|
|
* @scenario resource=layout_extension,endpoint=list,observation=response_payload
|
|
*
|
|
* @effects list_omits_extension_content
|
|
*/
|
|
public function test_index_omits_extension_content(): void
|
|
{
|
|
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',
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$extensions = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions']);
|
|
|
|
$this->assertGreaterThan(0, $extensions->count(), '검사할 확장이 있어야 한다');
|
|
|
|
foreach ($extensions as $extension) {
|
|
$this->assertArrayNotHasKey('content', $extension, '목록 응답에 content 가 실리면 안 된다');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* index - 라우트 트리가 쓰는 표시 필드는 목록에 그대로 남는다 (기능 축소 아님).
|
|
*/
|
|
public function test_index_keeps_fields_used_by_route_tree(): void
|
|
{
|
|
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',
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$extension = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions'])->first();
|
|
|
|
$this->assertNotNull($extension);
|
|
|
|
foreach ([
|
|
'id', 'extension_type', 'target_name', 'source_identifier', 'source_label',
|
|
'is_override', 'priority', 'is_active', 'is_modified', 'host_layouts', 'current_version',
|
|
// 목록 화면이 확장마다 크기를 표시한다 — 빼면 전 행이 '0 B' 로 보인다
|
|
'size', 'size_formatted',
|
|
] as $field) {
|
|
$this->assertArrayHasKey($field, $extension, "라우트 트리가 쓰는 {$field} 가 사라졌다");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 목록의 크기 표기가 실제 본문 크기를 반영하는지 확인합니다.
|
|
*
|
|
* 본문(`content`)은 목록에서 빠지지만 크기는 서버가 계산해 내려준다. 키만 있고 값이 0 이면
|
|
* 화면에는 모든 확장이 `0 B` 로 나타나므로, 키 존재만으로는 회귀를 잡지 못한다.
|
|
*
|
|
* @effects extension_size_matches_actual_content_length
|
|
*/
|
|
public function test_index_reports_actual_content_size(): void
|
|
{
|
|
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' => ['components' => [['type' => 'basic', 'name' => 'Div']]],
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$extension = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions'])->first();
|
|
|
|
$this->assertGreaterThan(0, $extension['size']);
|
|
$this->assertNotSame('0 B', $extension['size_formatted']);
|
|
}
|
|
|
|
/**
|
|
* 목록에서 뺀 본문은 단건 조회가 여전히 공급한다.
|
|
*
|
|
* @scenario resource=layout_extension,endpoint=detail,observation=response_payload
|
|
*
|
|
* @effects detail_still_returns_full_payload
|
|
*/
|
|
public function test_show_still_provides_extension_content(): void
|
|
{
|
|
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',
|
|
'priority' => 100,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
$response->assertStatus(200);
|
|
|
|
$extensionId = collect($response->json('data'))->flatMap(fn ($g) => $g['extensions'])->first()['id'];
|
|
|
|
$detail = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extensionId}");
|
|
|
|
$detail->assertStatus(200);
|
|
$this->assertArrayHasKey('content', $detail->json('data'));
|
|
$this->assertArrayHasKey('lock_version', $detail->json('data'));
|
|
}
|
|
|
|
/**
|
|
* index - 권한 없는 사용자 403
|
|
*/
|
|
public function test_index_forbidden_for_unauthorized_user(): void
|
|
{
|
|
$response = $this->actingAs($this->normalUser)
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* show - content 직렬화
|
|
*/
|
|
public function test_show_returns_extension_with_serialized_content(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.id', $extension->id)
|
|
->assertJsonStructure(['data' => ['id', 'extension_type', 'target_name', 'content', 'is_modified']]);
|
|
|
|
$this->assertIsString($response->json('data.content'));
|
|
}
|
|
|
|
/**
|
|
* show - 호스트 레이아웃 후보 반환
|
|
*/
|
|
public function test_show_returns_host_layouts_for_overlay(): void
|
|
{
|
|
// 호스트 레이아웃: injection target_id 노드가 실재 (편집 가능 컴포넌트를
|
|
// 실재 target_id 에 주입하는 overlay 만 시각 편집 호스트로 인정).
|
|
TemplateLayout::create([
|
|
'template_id' => $this->template->id,
|
|
'name' => 'admin_user_detail',
|
|
'content' => ['components' => [['name' => 'Div', 'props' => ['id' => 'detail_panel']]]],
|
|
]);
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-test',
|
|
'content' => [
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [['target_id' => 'detail_panel', 'position' => 'append_child', 'components' => [['name' => 'Div']]]],
|
|
],
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.host_layouts', ['admin_user_detail']);
|
|
}
|
|
|
|
/**
|
|
* 시각 편집기 inject_props 교차 저장 페이로드 — 호스트 노드
|
|
* 속성 모달에서 편집한 주입 props 가 그 확장 행으로 저장되고 original_content_hash 보존.
|
|
*/
|
|
public function test_inject_props_cross_save_preserves_hash(): void
|
|
{
|
|
$originalContent = [
|
|
'target_layout' => 'admin_user_detail',
|
|
'injections' => [
|
|
['target_id' => 'user_detail_tabs', 'position' => 'inject_props', 'props' => ['tabs' => ['_append' => [['id' => 'old']]]]],
|
|
],
|
|
];
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin_user_detail',
|
|
'source_type' => LayoutSourceType::Module,
|
|
'source_identifier' => 'sirsoft-test',
|
|
'content' => $originalContent,
|
|
'original_content_hash' => hash('sha256', json_encode($originalContent, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),
|
|
]);
|
|
$originalHash = $extension->original_content_hash;
|
|
|
|
// 시각 편집기 교차 저장 = injection props 만 교체한 전체 content PUT.
|
|
$newContent = $originalContent;
|
|
$newContent['injections'][0]['props'] = ['tabs' => ['_append' => [['id' => 'edited']]]];
|
|
|
|
$response = $this->authRequest()
|
|
->putJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}", [
|
|
'expected_lock_version' => 0,
|
|
'content' => $newContent,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
$extension->refresh();
|
|
// 주입 props 갱신 + 수정 감지용 original_content_hash 불변(보존).
|
|
$this->assertSame('edited', $extension->content['injections'][0]['props']['tabs']['_append'][0]['id']);
|
|
$this->assertEquals($originalHash, $extension->original_content_hash);
|
|
// 버전 적재(편집 이력).
|
|
$this->assertGreaterThanOrEqual(1, TemplateLayoutExtensionVersion::where('extension_id', $extension->id)->count());
|
|
}
|
|
|
|
/**
|
|
* show - 다른 템플릿의 확장 ID 는 404
|
|
*/
|
|
public function test_show_returns_404_for_extension_of_other_template(): void
|
|
{
|
|
$otherTemplate = Template::factory()->create();
|
|
$extension = LayoutExtension::factory()->create(['template_id' => $otherTemplate->id]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}");
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* update - 200 + 버전 2건 생성 + original_content_hash 불변
|
|
*/
|
|
public function test_update_succeeds_and_creates_versions(): void
|
|
{
|
|
$originalContent = ['extension_point' => 'header', 'components' => []];
|
|
$extension = $this->makeExtensionPoint([
|
|
'content' => $originalContent,
|
|
'original_content_hash' => hash('sha256', json_encode($originalContent, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),
|
|
]);
|
|
$originalHash = $extension->original_content_hash;
|
|
|
|
$newContent = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
|
|
|
$response = $this->authRequest()
|
|
->putJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}", [
|
|
'expected_lock_version' => 0,
|
|
'content' => $newContent,
|
|
]);
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$this->assertEquals(2, TemplateLayoutExtensionVersion::where('extension_id', $extension->id)->count());
|
|
|
|
$extension->refresh();
|
|
$this->assertEquals($newContent, $extension->content);
|
|
$this->assertEquals($originalHash, $extension->original_content_hash);
|
|
|
|
// 현재(최신) 버전 번호 동봉.
|
|
// 첫 저장은 baseline(v1) + 이번 저장본(v2) 두 버전 적재 → current_version=2.
|
|
$this->assertSame(2, $response->json('data.current_version'));
|
|
}
|
|
|
|
/**
|
|
* index - 각 확장에 current_version 부착
|
|
*
|
|
* 라우트 트리 확장 노드 버전 배지의 데이터 소스 — 버전 이력이 있는 확장은 최신 버전
|
|
* 번호, 이력이 없는(원본) 확장은 null 이어야 한다(배지 미표시).
|
|
*/
|
|
public function test_index_attaches_current_version_to_each_extension(): void
|
|
{
|
|
$withHistory = $this->makeExtensionPoint();
|
|
TemplateLayoutExtensionVersion::factory()->create(['extension_id' => $withHistory->id, 'version' => 1]);
|
|
TemplateLayoutExtensionVersion::factory()->create(['extension_id' => $withHistory->id, 'version' => 5]);
|
|
$noHistory = $this->makeExtensionPoint();
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$byId = [];
|
|
foreach ($response->json('data') as $group) {
|
|
foreach ($group['extensions'] as $ext) {
|
|
$byId[$ext['id']] = $ext;
|
|
}
|
|
}
|
|
|
|
$this->assertArrayHasKey('current_version', $byId[$withHistory->id]);
|
|
$this->assertSame(5, $byId[$withHistory->id]['current_version']);
|
|
$this->assertNull($byId[$noHistory->id]['current_version']);
|
|
}
|
|
|
|
/**
|
|
* update - expected_lock_version 불일치 시 409 Conflict (낙관적 잠금)
|
|
*/
|
|
public function test_update_returns_409_on_lock_version_mismatch(): void
|
|
{
|
|
// 신규 확장은 lock_version=0. expected_lock_version=1 을 보내면 불일치 → 409.
|
|
$extension = $this->makeExtensionPoint([
|
|
'content' => ['extension_point' => 'header', 'components' => []],
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->putJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}", [
|
|
'expected_lock_version' => 1,
|
|
'content' => ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]],
|
|
]);
|
|
|
|
$response->assertStatus(409)
|
|
->assertJsonPath('errors.error', 'concurrent_modification')
|
|
->assertJsonPath('errors.current_version', 0)
|
|
->assertJsonPath('errors.your_version', 1);
|
|
|
|
// 충돌 시 저장되지 않음 — content 불변 + 버전 미적재.
|
|
$extension->refresh();
|
|
$this->assertEquals([], $extension->content['components']);
|
|
$this->assertEquals(0, TemplateLayoutExtensionVersion::where('extension_id', $extension->id)->count());
|
|
}
|
|
|
|
/**
|
|
* update - 잘못된 구조는 422
|
|
*/
|
|
public function test_update_rejects_invalid_structure(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
// extension_point 와 target_layout 둘 다 없는 잘못된 content
|
|
$response = $this->authRequest()
|
|
->putJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}", [
|
|
'expected_lock_version' => 0,
|
|
'content' => ['components' => []],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
|
|
/**
|
|
* update - 권한 없는 사용자 403
|
|
*/
|
|
public function test_update_forbidden_for_unauthorized_user(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
$response = $this->actingAs($this->normalUser)
|
|
->putJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}", [
|
|
'content' => ['extension_point' => 'header', 'components' => []],
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* versions - 버전 목록 조회
|
|
*/
|
|
public function test_versions_returns_version_list(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
TemplateLayoutExtensionVersion::factory()->count(3)->sequence(
|
|
['version' => 1],
|
|
['version' => 2],
|
|
['version' => 3],
|
|
)->create(['extension_id' => $extension->id]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions");
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertCount(3, $response->json('data'));
|
|
}
|
|
|
|
/**
|
|
* showVersion - 특정 버전 조회
|
|
*/
|
|
public function test_show_version_returns_specific_version(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
TemplateLayoutExtensionVersion::factory()->create([
|
|
'extension_id' => $extension->id,
|
|
'version' => 1,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions/1");
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.version', 1);
|
|
}
|
|
|
|
/**
|
|
* showVersion - 단건 조회는 확장 본문(content)을 공급한다
|
|
*
|
|
* 목록에서 content 를 뺀 대신 단건이 대체 경로가 된다. 단건이 목록용 조회를 재사용하면
|
|
* content 가 조회되지 않아 버전 비교 diff 가 "전 항목 삭제"로 표시된다.
|
|
*
|
|
* @effects version_detail_still_returns_content
|
|
*/
|
|
public function test_show_version_still_provides_extension_content(): void
|
|
{
|
|
$content = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
|
$extension = $this->makeExtensionPoint();
|
|
TemplateLayoutExtensionVersion::factory()->create([
|
|
'extension_id' => $extension->id,
|
|
'version' => 1,
|
|
'content' => $content,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions/1");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$payload = $response->json('data.content');
|
|
$this->assertIsString($payload, '단건 버전 응답의 content 는 JSON 문자열이어야 한다');
|
|
$this->assertNotSame('[]', $payload, '단건 버전 조회가 확장 본문을 잃으면 버전 비교가 전 항목 삭제로 표시된다');
|
|
$this->assertSame($content, json_decode($payload, true));
|
|
}
|
|
|
|
/**
|
|
* showVersion - 목록 상한 밖의 오래된 버전도 단건으로 조회된다
|
|
*
|
|
* 단건이 목록용 조회(상한 100건)를 재사용하면 101번째 이전 버전이 404 가 된다.
|
|
*
|
|
* @effects version_detail_reaches_rows_beyond_list_cap
|
|
*/
|
|
public function test_show_version_reaches_versions_beyond_the_list_cap(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
// 버전 1 = 가장 오래된 버전. 최신순 상한 100건 안에 들지 못한다.
|
|
for ($version = 1; $version <= 101; $version++) {
|
|
TemplateLayoutExtensionVersion::factory()->create([
|
|
'extension_id' => $extension->id,
|
|
'version' => $version,
|
|
]);
|
|
}
|
|
|
|
$this->authRequest()
|
|
->getJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions/1")
|
|
->assertStatus(200)
|
|
->assertJsonPath('data.version', 1);
|
|
}
|
|
|
|
/**
|
|
* restoreVersion - content 복원 + 새 버전 생성
|
|
*/
|
|
public function test_restore_version_restores_content(): void
|
|
{
|
|
$oldContent = ['extension_point' => 'header', 'components' => [['type' => 'basic', 'name' => 'Span']]];
|
|
$extension = $this->makeExtensionPoint(['content' => ['extension_point' => 'footer']]);
|
|
|
|
$version = TemplateLayoutExtensionVersion::factory()->create([
|
|
'extension_id' => $extension->id,
|
|
'version' => 1,
|
|
'content' => $oldContent,
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->postJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions/{$version->id}/restore");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$extension->refresh();
|
|
$this->assertEquals($oldContent, $extension->content);
|
|
}
|
|
|
|
/**
|
|
* restoreVersion - 존재하지 않는 버전 404
|
|
*/
|
|
public function test_restore_version_returns_404_for_missing_version(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
$response = $this->authRequest()
|
|
->postJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/versions/99999/restore");
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* storePreview - overlay 타입 미리보기 토큰 반환
|
|
*/
|
|
public function test_store_preview_returns_token_for_overlay(): void
|
|
{
|
|
$extension = LayoutExtension::factory()->create([
|
|
'template_id' => $this->template->id,
|
|
'extension_type' => LayoutExtensionType::Overlay,
|
|
'target_name' => 'admin/dashboard',
|
|
'source_type' => LayoutSourceType::Plugin,
|
|
'source_identifier' => 'sirsoft-analytics',
|
|
'content' => ['target_layout' => 'admin/dashboard', 'injections' => []],
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->postJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/preview", [
|
|
'content' => ['target_layout' => 'admin/dashboard', 'injections' => []],
|
|
]);
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure(['data' => ['token', 'preview_url', 'expires_at']]);
|
|
}
|
|
|
|
/**
|
|
* storePreview - extension_point 타입은 preview_layout 없으면 422
|
|
*/
|
|
public function test_store_preview_requires_preview_layout_for_extension_point(): void
|
|
{
|
|
$extension = $this->makeExtensionPoint();
|
|
|
|
$response = $this->authRequest()
|
|
->postJson("/api/admin/templates/{$this->template->identifier}/layout-extensions/{$extension->id}/preview", [
|
|
'content' => ['extension_point' => 'header', 'components' => []],
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
}
|
|
}
|