Files
Gnuboard7/tests/Feature/Api/Admin/LayoutExtensionControllerTest.php
T
HeuJung b5c0ec1178 fix(core,ecommerce,board,page,admin_basic,basic): 목록 응답의 하위 컬렉션 전량 직렬화 해소
관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다.
같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다.

근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서
관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을
정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면
정상이라 오류도 경고도 없이 페이로드만 불어난다.

목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB
집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의
limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다.

뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와
무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다.
배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어
제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다.

재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
2026-08-05 21:20:05 +09:00

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);
}
}