Files
Gnuboard7/tests/Feature/Api/Admin/TemplateControllerTest.php
T
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

1637 lines
55 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Contracts\Extension\TemplateManagerInterface;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\User;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Validation\ValidationException;
use Mockery;
use Tests\TestCase;
/**
* TemplateController API 테스트
*
* GET /api/admin/templates 엔드포인트 테스트
* GET /api/admin/templates/{templateName} 엔드포인트 테스트
* POST /api/admin/templates/install 엔드포인트 테스트
* POST /api/admin/templates/activate 엔드포인트 테스트
* POST /api/admin/templates/deactivate 엔드포인트 테스트
* DELETE /api/admin/templates/uninstall 엔드포인트 테스트
*/
class TemplateControllerTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private User $normalUser;
private string $adminToken;
private string $normalToken;
protected function setUp(): void
{
parent::setUp();
// TemplateManager를 Mock하여 파일 시스템 의존성 제거
$this->mockTemplateManager();
// 관리자 사용자 생성 (모든 템플릿 권한)
$this->adminUser = $this->createAdminUser([
'core.templates.read',
'core.templates.install',
'core.templates.activate',
'core.templates.uninstall',
]);
$this->adminToken = $this->adminUser->createToken('test-token')->plainTextToken;
// 일반 사용자 생성 (역할 없음)
$this->normalUser = User::factory()->create();
$this->normalToken = $this->normalUser->createToken('test-token')->plainTextToken;
}
/**
* 관리자 역할 생성 및 할당
*
* 중요: AdminMiddleware가 isAdmin()을 체크하고, isAdmin()은 hasRole('admin')을 확인합니다.
* 따라서 역할 identifier는 반드시 'admin'이어야 합니다.
*
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
*/
private function createAdminUser(array $permissions = []): User
{
$user = User::factory()->create();
// 권한 생성
$permissionIds = [];
foreach ($permissions as $permIdentifier) {
$permission = Permission::firstOrCreate(
['identifier' => $permIdentifier],
[
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$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->adminToken,
'Accept' => 'application/json',
]);
}
/**
* 일반 사용자 요청 헬퍼 메서드
*/
private function normalUserRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->normalToken,
'Accept' => 'application/json',
]);
}
/**
* TemplateManager를 Mock 처리합니다.
*/
protected function mockTemplateManager(): void
{
$mock = Mockery::mock(TemplateManagerInterface::class);
// 기본 동작 설정
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn([]);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$mock->shouldReceive('getTemplateInfo')->andReturn(null);
$mock->shouldReceive('installTemplate')->andReturn(true);
$mock->shouldReceive('uninstallTemplate')->andReturn(true);
$mock->shouldReceive('activateTemplate')->andReturn(true);
$mock->shouldReceive('deactivateTemplate')->andReturn(true);
$this->app->instance(TemplateManagerInterface::class, $mock);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
// ========================================
// index() 테스트
// ========================================
/**
* 템플릿 목록 조회 성공
*/
public function test_can_list_templates(): void
{
// Arrange: Mock에서 반환할 데이터 설정
$installedTemplates = [
[
'identifier' => 'test-template1',
'vendor' => 'test',
'name' => 'Test Template 1',
'version' => '1.0.0',
'type' => 'user',
'status' => 'active',
'description' => 'Test description 1',
],
[
'identifier' => 'test-template2',
'vendor' => 'test',
'name' => 'Test Template 2',
'version' => '1.0.0',
'type' => 'admin',
'status' => 'inactive',
'description' => 'Test description 2',
],
];
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson('/api/admin/templates');
// Assert
$response->assertStatus(200)
->assertJsonStructure([
'success',
'message',
'data' => [
'data',
'pagination' => [
'total',
'current_page',
'last_page',
'per_page',
],
],
]);
$this->assertEquals(2, $response->json('data.pagination.total'));
}
/**
* type=user 필터링 테스트
*/
public function test_can_filter_templates_by_type_user(): void
{
// Arrange
$installedTemplates = [
[
'identifier' => 'test-template1',
'vendor' => 'test',
'name' => 'Test Template 1',
'version' => '1.0.0',
'type' => 'user',
'status' => 'active',
'description' => 'Test description',
],
[
'identifier' => 'test-template2',
'vendor' => 'test',
'name' => 'Test Template 2',
'version' => '1.0.0',
'type' => 'admin',
'status' => 'inactive',
'description' => 'Test description',
],
];
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson('/api/admin/templates?type=user');
// Assert
$response->assertStatus(200);
$this->assertEquals(1, $response->json('data.pagination.total'));
}
/**
* type=admin 필터링 테스트
*/
public function test_can_filter_templates_by_type_admin(): void
{
// Arrange
$installedTemplates = [
[
'identifier' => 'test-template1',
'vendor' => 'test',
'name' => 'Test Template 1',
'version' => '1.0.0',
'type' => 'user',
'status' => 'active',
'description' => 'Test description',
],
[
'identifier' => 'test-template2',
'vendor' => 'test',
'name' => 'Test Template 2',
'version' => '1.0.0',
'type' => 'admin',
'status' => 'inactive',
'description' => 'Test description',
],
];
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getInstalledTemplatesWithDetails')->andReturn($installedTemplates);
$mock->shouldReceive('getUninstalledTemplates')->andReturn([]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson('/api/admin/templates?type=admin');
// Assert
$response->assertStatus(200);
$this->assertEquals(1, $response->json('data.pagination.total'));
}
/**
* 잘못된 type 파라미터 검증
*/
public function test_rejects_invalid_type_parameter(): void
{
// Act
$response = $this->authRequest()->getJson('/api/admin/templates?type=invalid');
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['type']);
}
/**
* 인증되지 않은 사용자 접근 거부
*/
public function test_unauthenticated_user_cannot_access(): void
{
// Act
$response = $this->getJson('/api/admin/templates');
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자 접근 거부
*/
public function test_unauthorized_user_cannot_access(): void
{
// Act
$response = $this->normalUserRequest()->getJson('/api/admin/templates');
// Assert
$response->assertStatus(403);
}
/**
* 빈 목록 조회 (템플릿이 없을 때)
*/
public function test_returns_empty_array_when_no_templates(): void
{
// Act
$response = $this->authRequest()->getJson('/api/admin/templates');
// Assert
$response->assertStatus(200);
$this->assertEquals(0, $response->json('data.total'));
$this->assertEmpty($response->json('data.data'));
}
/**
* 전체 권한 유저의 index 응답에 abilities 포함 확인
*/
public function test_index_returns_abilities_for_full_permission_user(): void
{
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->adminToken,
'Accept' => 'application/json',
])->getJson('/api/admin/templates');
$response->assertStatus(200)
->assertJsonPath('data.abilities.can_install', true)
->assertJsonPath('data.abilities.can_activate', true)
->assertJsonPath('data.abilities.can_uninstall', true);
}
/**
* read 전용 유저의 index 응답에 abilities false 확인
*/
public function test_index_returns_false_abilities_for_read_only_user(): void
{
$readOnlyUser = $this->createAdminUser(['core.templates.read']);
$readOnlyToken = $readOnlyUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readOnlyToken,
'Accept' => 'application/json',
])->getJson('/api/admin/templates');
$response->assertStatus(200)
->assertJsonPath('data.abilities.can_install', false)
->assertJsonPath('data.abilities.can_activate', false)
->assertJsonPath('data.abilities.can_uninstall', false);
}
// ========================================
// show() 테스트
// ========================================
/**
* 템플릿 상세 조회 성공
*/
public function test_can_show_template(): void
{
// Arrange - 고유 identifier 사용하여 트랜잭션 락 충돌 방지
$identifier = 'test-template-'.uniqid();
$template = Template::factory()->create([
'identifier' => $identifier,
'vendor' => 'test-vendor',
'name' => [
'ko' => '테스트 템플릿',
'en' => 'Test Template',
],
'version' => '1.0.0',
'type' => 'user',
'status' => 'active',
'description' => [
'ko' => '테스트 설명',
'en' => 'Test description',
],
]);
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => $template->status,
'description' => $template->description,
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(200)
->assertJsonStructure([
'success',
'message',
'data' => [
'identifier',
'vendor',
'name',
'version',
'type',
'status',
],
])
->assertJsonFragment([
'identifier' => $identifier,
'vendor' => 'test-vendor',
'version' => '1.0.0',
'type' => 'user',
'status' => 'active',
]);
}
/**
* 존재하지 않는 템플릿 조회 시 404 응답
*/
public function test_returns_404_for_nonexistent_template(): void
{
// Act
$response = $this->authRequest()->getJson('/api/admin/templates/nonexistent-template');
// Assert
$response->assertStatus(404);
}
/**
* 인증되지 않은 사용자는 템플릿 상세 조회 불가
*/
public function test_unauthenticated_user_cannot_show_template(): void
{
// Arrange
$template = Template::factory()->create();
// Act
$response = $this->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 템플릿 상세 조회 불가
*/
public function test_unauthorized_user_cannot_show_template(): void
{
// Arrange
$template = Template::factory()->create();
// Act
$response = $this->normalUserRequest()->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(403);
}
/**
* 템플릿 상세 조회 시 민감한 정보 제외
*/
public function test_show_response_excludes_sensitive_fields(): void
{
// Arrange
$template = Template::factory()->create();
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => $template->status,
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(200);
$data = $response->json('data');
$this->assertArrayNotHasKey('created_by', $data);
$this->assertArrayNotHasKey('updated_by', $data);
$this->assertArrayNotHasKey('deleted_at', $data);
}
/**
* 상세 응답에 template.json 의 externals 가 정규화되어 포함된다.
*/
public function test_show_response_includes_normalized_externals(): void
{
// Arrange
$template = Template::factory()->create();
// Mock 설정 — externals 에 정규화 대상(HTTP URL, 중복) 포함
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => $template->status,
'externals' => [
[
'id' => 'fontawesome',
'type' => 'style',
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
],
// 중복 — normalize 가 제거해야 함
[
'id' => 'fontawesome-dup',
'type' => 'style',
'url' => 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
],
// HTTP URL — normalize 가 탈락시켜야 함
[
'id' => 'insecure',
'type' => 'style',
'url' => 'http://insecure.example.com/style.css',
],
],
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(200);
$externals = $response->json('data.externals');
$this->assertIsArray($externals);
// 중복 1건 + HTTP 1건 탈락 → 1건만 남음
$this->assertCount(1, $externals);
$this->assertSame('style', $externals[0]['type']);
$this->assertSame(
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
$externals[0]['url'],
);
}
/**
* externals 가 없는 템플릿은 상세 응답에서 빈 배열로 노출된다.
*/
public function test_show_response_externals_defaults_to_empty_array(): void
{
// Arrange
$template = Template::factory()->create();
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplateInfo')->andReturn([
'id' => $template->id,
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => $template->status,
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->getJson("/api/admin/templates/{$template->identifier}");
// Assert
$response->assertStatus(200);
$this->assertSame([], $response->json('data.externals'));
}
// ========================================
// activate() 테스트
// ========================================
/**
* 템플릿 활성화 성공
*/
public function test_can_activate_template(): void
{
// Arrange
$template = Template::factory()->create([
'status' => 'inactive',
]);
// Mock 설정
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
]);
$mock->shouldReceive('getTemplateInfo')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => 'active',
]);
$mock->shouldReceive('activateTemplate')->andReturnUsing(function () use ($template) {
$template->update(['status' => 'active']);
return [
'success' => true,
'template_info' => [
'identifier' => $template->identifier,
'name' => $template->name,
'status' => 'active',
],
];
});
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['status' => 'active']);
$this->assertDatabaseHas('templates', [
'id' => $template->id,
'status' => 'active',
]);
}
/**
* 템플릿 활성화 시 template_name 필수
*/
public function test_activate_requires_template_name(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 존재하지 않는 템플릿 활성화 시 422 응답 (ValidationException)
*/
public function test_activate_nonexistent_template_returns_422(): void
{
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')->andThrow(
ValidationException::withMessages([
'template' => ['No active template found'],
])
);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', [
'template_name' => 'nonexistent-template',
]);
// Assert
$response->assertStatus(422);
}
/**
* 권한 없는 사용자는 템플릿 활성화 불가
*/
public function test_unauthorized_user_cannot_activate_template(): void
{
// Arrange
$template = Template::factory()->create(['status' => 'inactive']);
// Act
$response = $this->normalUserRequest()->postJson('/api/admin/templates/activate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(403);
}
/**
* 인증되지 않은 사용자는 템플릿 활성화 불가
*/
public function test_unauthenticated_user_cannot_activate_template(): void
{
// Act
$response = $this->postJson('/api/admin/templates/activate', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(401);
}
// ========================================
// install() 테스트
// ========================================
/**
* 템플릿 설치 성공
*/
public function test_can_install_template(): void
{
// Arrange
$templateIdentifier = 'test-install-template';
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $templateIdentifier,
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
]);
$mock->shouldReceive('installTemplate')->andReturn(true);
$mock->shouldReceive('getTemplateInfo')->andReturn([
'identifier' => $templateIdentifier,
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
'version' => '1.0.0',
'type' => 'admin',
'status' => 'inactive',
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install', [
'template_name' => $templateIdentifier,
]);
// Assert
$response->assertStatus(201)
->assertJsonFragment(['success' => true]);
}
/**
* 템플릿 설치 시 template_name 필수
*/
public function test_install_template_requires_template_name(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 존재하지 않는 템플릿 설치 시 422 응답 (ValidationException)
*/
public function test_install_nonexistent_template_fails(): void
{
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn(null);
$mock->shouldReceive('installTemplate')->andThrow(
ValidationException::withMessages([
'identifier' => ['Failed to install template.: Template not found'],
])
);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install', [
'template_name' => 'nonexistent-template',
]);
// Assert
$response->assertStatus(422);
}
/**
* 인증되지 않은 사용자는 템플릿 설치 불가
*/
public function test_unauthenticated_user_cannot_install_template(): void
{
// Act
$response = $this->postJson('/api/admin/templates/install', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 템플릿 설치 불가
*/
public function test_unauthorized_user_cannot_install_template(): void
{
// Act
$response = $this->normalUserRequest()->postJson('/api/admin/templates/install', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(403);
}
// ========================================
// installFromFile() 테스트
// ========================================
/**
* ZIP 파일에서 템플릿 설치 시 파일 필수
*/
public function test_install_from_file_requires_file(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-file', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['file']);
}
/**
* ZIP 파일이 아닌 파일 업로드 시 실패
*/
public function test_install_from_file_rejects_non_zip_file(): void
{
// Arrange
$file = UploadedFile::fake()->create('test.txt', 100, 'text/plain');
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['file']);
}
/**
* 인증되지 않은 사용자는 파일 설치 불가
*/
public function test_unauthenticated_user_cannot_install_from_file(): void
{
// Arrange
$file = UploadedFile::fake()->create('test.zip', 100, 'application/zip');
// Act
$response = $this->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 파일 설치 불가
*/
public function test_unauthorized_user_cannot_install_from_file(): void
{
// Arrange
$file = UploadedFile::fake()->create('test.zip', 100, 'application/zip');
// Act
$response = $this->normalUserRequest()->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
// Assert
$response->assertStatus(403);
}
// ========================================
// installFromGithub() 테스트
// ========================================
/**
* GitHub URL 필수
*/
public function test_install_from_github_requires_url(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* 잘못된 GitHub URL 형식 거부
*/
public function test_install_from_github_rejects_invalid_url(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'not-a-valid-url',
]);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* GitHub 이외의 URL 거부
*/
public function test_install_from_github_rejects_non_github_url(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://gitlab.com/user/repo',
]);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* 유효한 ZIP 파일로 템플릿 설치 성공 (Service Mock)
*/
public function test_install_from_file_succeeds_with_valid_zip(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromZipFile')
->once()
->andReturn([
'identifier' => 'sirsoft-testtemplate',
'name' => 'Test Template',
'version' => '1.0.0',
]);
$this->app->instance(TemplateService::class, $templateServiceMock);
$file = UploadedFile::fake()->create('template.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
$response->assertStatus(201)
->assertJson(['success' => true])
->assertJsonPath('data.identifier', 'sirsoft-testtemplate');
}
/**
* TemplateService에서 RuntimeException 발생 시 422 반환
*/
public function test_install_from_file_returns_422_on_runtime_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \RuntimeException('template.json을 찾을 수 없습니다.'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$file = UploadedFile::fake()->create('template.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'template.json을 찾을 수 없습니다.');
}
/**
* TemplateService에서 일반 Exception 발생 시 500 반환
*/
public function test_install_from_file_returns_500_on_general_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$file = UploadedFile::fake()->create('template.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-file', [
'file' => $file,
]);
$response->assertStatus(500);
}
// ========================================
// GitHub 설치 - Service Mock 테스트
// ========================================
/**
* 유효한 GitHub URL로 템플릿 설치 성공 (Service Mock)
*/
public function test_install_from_github_calls_service_with_valid_url(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromGithub')
->once()
->with('https://github.com/sirsoft/sample-template')
->andReturn([
'identifier' => 'sirsoft-sampletemplate',
'name' => 'Sample Template',
'version' => '1.0.0',
]);
$this->app->instance(TemplateService::class, $templateServiceMock);
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-template',
]);
$response->assertStatus(201)
->assertJson(['success' => true])
->assertJsonPath('data.identifier', 'sirsoft-sampletemplate');
}
/**
* TemplateService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/
public function test_install_from_github_returns_422_on_runtime_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-template',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.');
}
/**
* TemplateService에서 일반 Exception 발생 시 500 반환 (GitHub)
*/
public function test_install_from_github_returns_500_on_general_exception(): void
{
$templateServiceMock = Mockery::mock(TemplateService::class);
$templateServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(TemplateService::class, $templateServiceMock);
$response = $this->authRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-template',
]);
$response->assertStatus(500);
}
/**
* 인증되지 않은 사용자는 GitHub 설치 불가
*/
public function test_unauthenticated_user_cannot_install_from_github(): void
{
// Act
$response = $this->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://github.com/user/repo',
]);
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 GitHub 설치 불가
*/
public function test_unauthorized_user_cannot_install_from_github(): void
{
// Act
$response = $this->normalUserRequest()->postJson('/api/admin/templates/install-from-github', [
'github_url' => 'https://github.com/user/repo',
]);
// Assert
$response->assertStatus(403);
}
// ========================================
// uninstall() 테스트
// ========================================
/**
* 템플릿 제거 성공
*/
public function test_can_uninstall_template(): void
{
// Arrange
$template = Template::factory()->create([
'status' => 'inactive',
]);
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
$mock->shouldReceive('getTemplateInfo')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'status' => $template->status,
]);
$mock->shouldReceive('uninstallTemplate')->andReturnUsing(function () use ($template) {
$template->delete();
return true;
});
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->deleteJson('/api/admin/templates/uninstall', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['success' => true]);
}
/**
* 템플릿 제거 시 template_name 필수
*/
public function test_uninstall_requires_template_name(): void
{
// Act
$response = $this->authRequest()->deleteJson('/api/admin/templates/uninstall', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 인증되지 않은 사용자는 템플릿 제거 불가
*/
public function test_unauthenticated_user_cannot_uninstall_template(): void
{
// Act
$response = $this->deleteJson('/api/admin/templates/uninstall', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 템플릿 제거 불가
*/
public function test_unauthorized_user_cannot_uninstall_template(): void
{
// Act
$response = $this->normalUserRequest()->deleteJson('/api/admin/templates/uninstall', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(403);
}
// ========================================
// deactivate() 테스트
// ========================================
/**
* 템플릿 비활성화 성공
*/
public function test_can_deactivate_template(): void
{
// Arrange
$template = Template::factory()->create([
'type' => 'admin',
'status' => 'active',
]);
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
$mock->shouldReceive('getTemplateInfo')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => 'inactive',
]);
$mock->shouldReceive('deactivateTemplate')->andReturnUsing(function () use ($template) {
$template->update(['status' => 'inactive']);
return true;
});
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/deactivate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['success' => true]);
$this->assertDatabaseHas('templates', [
'id' => $template->id,
'status' => 'inactive',
]);
}
/**
* 템플릿 비활성화 시 template_name 필수
*/
public function test_deactivate_requires_template_name(): void
{
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/deactivate', []);
// Assert
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 존재하지 않는 템플릿 비활성화 시 422 응답 (ValidationException)
*/
public function test_deactivate_nonexistent_template_fails(): void
{
// Arrange
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('deactivateTemplate')->andThrow(
ValidationException::withMessages([
'template' => ['No active template found'],
])
);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/deactivate', [
'template_name' => 'nonexistent-template',
]);
// Assert
$response->assertStatus(422);
}
/**
* 이미 비활성화된 템플릿 비활성화 시 실패
*/
public function test_deactivate_inactive_template_fails(): void
{
// Arrange
$template = Template::factory()->create([
'status' => 'inactive',
]);
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('getTemplate')->andReturn([
'identifier' => $template->identifier,
]);
$mock->shouldReceive('deactivateTemplate')->andReturn(false);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/deactivate', [
'template_name' => $template->identifier,
]);
// Assert
// 비활성화 실패 시 에러 응답
$response->assertStatus(400);
}
/**
* 인증되지 않은 사용자는 템플릿 비활성화 불가
*/
public function test_unauthenticated_user_cannot_deactivate_template(): void
{
// Act
$response = $this->postJson('/api/admin/templates/deactivate', [
'template_name' => 'test-template',
]);
// Assert
$response->assertStatus(401);
}
/**
* 권한 없는 사용자는 템플릿 비활성화 불가
*/
public function test_unauthorized_user_cannot_deactivate_template(): void
{
// Arrange
$template = Template::factory()->create(['status' => 'active']);
// Act
$response = $this->normalUserRequest()->postJson('/api/admin/templates/deactivate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(403);
}
// ========================================
// 활성화 의존성 체크 테스트
// ========================================
/**
* 템플릿 활성화 시 필요한 의존성이 충족되지 않으면 409 경고 응답
*/
public function test_activate_returns_409_when_dependencies_not_met(): void
{
// Arrange
$template = Template::factory()->create([
'identifier' => 'test-dependent-template',
'status' => 'inactive',
]);
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, false)
->andReturn([
'success' => false,
'warning' => true,
'missing_modules' => [
['identifier' => 'sirsoft-ecommerce', 'name' => 'sirsoft-ecommerce', 'status' => 'not_installed'],
],
'missing_plugins' => [
['identifier' => 'sirsoft-payment', 'name' => 'sirsoft-payment', 'status' => 'inactive'],
],
'message' => '이 템플릿을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(409)
->assertJsonStructure([
'success',
'message',
'errors' => [
'warning',
'missing_modules',
'missing_plugins',
],
])
->assertJsonFragment(['warning' => true]);
// missing_modules 확인
$this->assertNotEmpty($response->json('errors.missing_modules'));
$this->assertEquals('sirsoft-ecommerce', $response->json('errors.missing_modules.0.identifier'));
// missing_plugins 확인
$this->assertNotEmpty($response->json('errors.missing_plugins'));
$this->assertEquals('sirsoft-payment', $response->json('errors.missing_plugins.0.identifier'));
}
/**
* 템플릿 활성화 시 force=true로 의존성 경고 무시하고 강제 활성화
*/
public function test_activate_with_force_bypasses_dependency_check(): void
{
// Arrange
$template = Template::factory()->create([
'identifier' => 'test-force-template',
'status' => 'inactive',
]);
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, true)
->andReturnUsing(function () use ($template) {
$template->update(['status' => 'active']);
return ['success' => true];
});
$mock->shouldReceive('getTemplateInfo')->andReturn([
'identifier' => $template->identifier,
'vendor' => $template->vendor,
'name' => $template->name,
'version' => $template->version,
'type' => $template->type,
'status' => 'active',
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', [
'template_name' => $template->identifier,
'force' => true,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['status' => 'active']);
$this->assertDatabaseHas('templates', [
'id' => $template->id,
'status' => 'active',
]);
}
/**
* 템플릿 의존성 경고 응답에서 missing_modules의 형식 검증
* dependencies.modules가 연관 배열 {"identifier": ">=version"} 형식일 때
* identifier가 올바르게 파싱되는지 확인
*/
public function test_activate_dependency_warning_parses_associative_array_format(): void
{
// Arrange
$template = Template::factory()->create([
'identifier' => 'test-assoc-deps',
'status' => 'inactive',
]);
// 연관 배열 형식의 dependencies를 가진 템플릿 시뮬레이션
// template.json: {"modules": {"sirsoft-board": ">=1.0.0", "sirsoft-ecommerce": ">=1.0.0"}}
$mock = Mockery::mock(TemplateManagerInterface::class);
$mock->shouldReceive('loadTemplates')->andReturnNull();
$mock->shouldReceive('ensureLoaded')->andReturnNull();
$mock->shouldReceive('activateTemplate')
->with($template->identifier, false)
->andReturn([
'success' => false,
'warning' => true,
'missing_modules' => [
['identifier' => 'sirsoft-board', 'name' => 'sirsoft-board', 'status' => 'not_installed'],
['identifier' => 'sirsoft-ecommerce', 'name' => 'sirsoft-ecommerce', 'status' => 'not_installed'],
],
'missing_plugins' => [],
'message' => '이 템플릿을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
]);
$this->app->instance(TemplateManagerInterface::class, $mock);
// Act
$response = $this->authRequest()->postJson('/api/admin/templates/activate', [
'template_name' => $template->identifier,
]);
// Assert
$response->assertStatus(409);
$missingModules = $response->json('errors.missing_modules');
$this->assertCount(2, $missingModules);
// identifier가 버전 문자열(>=1.0.0)이 아닌 실제 모듈 identifier인지 확인
$identifiers = array_column($missingModules, 'identifier');
$this->assertContains('sirsoft-board', $identifiers);
$this->assertContains('sirsoft-ecommerce', $identifiers);
$this->assertNotContains('>=1.0.0', $identifiers);
}
// ========================================================================
// Changelog 조회 테스트
// ========================================================================
/**
* CHANGELOG.md가 있는 템플릿의 changelog를 조회합니다.
*/
public function test_changelog_returns_parsed_entries(): void
{
$templatePath = base_path('templates/test-changelog-tpl');
File::ensureDirectoryExists($templatePath);
File::put($templatePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Changed\n- UI 개선\n");
try {
$response = $this->authRequest()->getJson('/api/admin/templates/test-changelog-tpl/changelog');
$response->assertStatus(200)
->assertJsonPath('data.changelog.0.version', '0.1.1')
->assertJsonPath('data.changelog.0.categories.0.name', 'Changed');
} finally {
File::deleteDirectory($templatePath);
}
}
/**
* CHANGELOG.md가 없는 템플릿은 빈 배열을 반환합니다.
*/
public function test_changelog_returns_empty_for_missing_file(): void
{
$response = $this->authRequest()->getJson('/api/admin/templates/nonexistent-template/changelog');
$response->assertStatus(200)
->assertJsonPath('data.changelog', []);
}
/**
* 인증 없이 changelog 조회 시 401 반환
*/
public function test_changelog_returns_401_without_authentication(): void
{
$response = $this->getJson('/api/admin/templates/test-template/changelog');
$response->assertStatus(401);
}
// ========================================================================
// 식별자 형식 검증 테스트
// ========================================================================
/**
* 잘못된 형식의 템플릿 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_invalid_identifier_format(): void
{
$response = $this->authRequest()->postJson('/api/admin/templates/install', [
'template_name' => 'sirsofttemplate', // 하이픈 없음
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 숫자로 시작하는 단어가 포함된 템플릿 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_digit_starting_identifier(): void
{
$response = $this->authRequest()->postJson('/api/admin/templates/install', [
'template_name' => 'sirsoft-2admin',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
/**
* 특수문자가 포함된 템플릿 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_special_char_identifier(): void
{
$response = $this->authRequest()->postJson('/api/admin/templates/install', [
'template_name' => 'sirsoft-admin@basic',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['template_name']);
}
}