Files
Gnuboard7/tests/Feature/Api/Admin/ModuleControllerTest.php
T
2026-04-01 10:30:52 +09:00

1077 lines
39 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* AdminModuleController 테스트
*
* 모듈 관리 API 엔드포인트를 테스트합니다.
*/
class ModuleControllerTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->admin = $this->createAdminUser();
$this->token = $this->admin->createToken('test-token')->plainTextToken;
}
/**
* 관리자 역할 생성 및 할당
*
* 중요: AdminMiddleware가 isAdmin()을 체크하고, isAdmin()은 hasRole('admin')을 확인합니다.
* 따라서 역할 identifier는 반드시 'admin'이어야 합니다.
*
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
*/
private function createAdminUser(array $permissions = ['core.modules.read', 'core.modules.install', 'core.modules.activate', 'core.modules.uninstall']): 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' => \App\Enums\PermissionType::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->token,
'Accept' => 'application/json',
]);
}
// ========================================================================
// 모듈 목록 조회 테스트 (index)
// ========================================================================
/**
* 인증 없이 모듈 목록 조회 시 401 반환
*/
public function test_index_returns_401_without_authentication(): void
{
$response = $this->getJson('/api/admin/modules');
$response->assertStatus(401);
}
/**
* 모듈 목록 조회 성공
*/
public function test_index_returns_modules_list(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'data',
'pagination' => [
'total',
'current_page',
'per_page',
],
],
]);
}
/**
* 페이지네이션 파라미터 테스트
*/
public function test_index_supports_pagination(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules?page=1&per_page=5');
$response->assertStatus(200)
->assertJsonPath('data.pagination.per_page', 5)
->assertJsonPath('data.pagination.current_page', 1);
}
/**
* with[] 파라미터로 custom_menus 조회 테스트
*/
public function test_index_returns_custom_menus_with_param(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules?with[]=custom_menus');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'data',
'custom_menus',
],
]);
}
/**
* with[] 파라미터 없이 조회 시 custom_menus가 없음
*/
public function test_index_does_not_return_custom_menus_without_param(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules');
$response->assertStatus(200)
->assertJsonMissing(['custom_menus']);
}
/**
* 유효하지 않은 with[] 파라미터 시 422 반환
*/
public function test_index_returns_422_for_invalid_with_param(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules?with[]=invalid_option');
$response->assertStatus(422)
->assertJsonValidationErrors(['with.0']);
}
// ========================================================================
// core.menus.read 권한으로 모듈 목록 접근 테스트
// ========================================================================
/**
* core.menus.read 권한만 가진 사용자가 모듈 목록 조회 시 200 반환
*/
public function test_index_returns_200_with_menus_read_permission(): void
{
$menuUser = $this->createAdminUser(['core.menus.read']);
$token = $menuUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->getJson('/api/admin/modules');
$response->assertStatus(200)
->assertJsonPath('data.data', [])
->assertJsonPath('data.pagination.total', 0);
}
/**
* core.menus.read 권한만 가진 사용자가 custom_menus 조회 시 데이터 반환
*/
public function test_index_returns_custom_menus_with_menus_read_permission(): void
{
$menuUser = $this->createAdminUser(['core.menus.read']);
$token = $menuUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->getJson('/api/admin/modules?with[]=custom_menus');
$response->assertStatus(200)
->assertJsonPath('data.data', [])
->assertJsonStructure([
'success',
'data' => [
'data',
'pagination',
'custom_menus',
],
]);
}
/**
* core.menus.read도 core.modules.read도 없는 사용자는 403 반환
*/
public function test_index_returns_403_without_any_relevant_permission(): void
{
$noPermUser = $this->createAdminUser([]);
$token = $noPermUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->getJson('/api/admin/modules');
$response->assertStatus(403);
}
// ========================================================================
// 모듈 검색 필터 테스트
// ========================================================================
/**
* 단일 검색어로 필터링 테스트
*/
public function test_index_filters_by_search(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules?search=sample');
$response->assertStatus(200);
}
/**
* filters 파라미터로 다중 검색 조건 테스트
*/
public function test_index_filters_with_multiple_conditions(): void
{
$filters = [
[
'field' => 'name',
'operator' => 'like',
'value' => 'sample',
],
];
$response = $this->authRequest()->getJson('/api/admin/modules?'.http_build_query(['filters' => $filters]));
$response->assertStatus(200);
}
/**
* status 필터링 테스트
*
* status는 별도의 top-level 파라미터로 처리됨
*/
public function test_index_filters_by_status(): void
{
// status는 filters가 아닌 별도의 파라미터
$response = $this->authRequest()->getJson('/api/admin/modules?status=active');
$response->assertStatus(200);
}
/**
* 전체 권한 유저의 index 응답에 abilities 포함 확인
*/
public function test_index_returns_abilities_for_full_permission_user(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules');
$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.modules.read']);
$readOnlyToken = $readOnlyUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readOnlyToken,
'Accept' => 'application/json',
])->getJson('/api/admin/modules');
$response->assertStatus(200)
->assertJsonPath('data.abilities.can_install', false)
->assertJsonPath('data.abilities.can_activate', false)
->assertJsonPath('data.abilities.can_uninstall', false);
}
/**
* 부분 권한 유저의 index 응답에 abilities 부분 확인
*/
public function test_index_returns_partial_abilities(): void
{
$partialUser = $this->createAdminUser(['core.modules.read', 'core.modules.install']);
$partialToken = $partialUser->createToken('test-token')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$partialToken,
'Accept' => 'application/json',
])->getJson('/api/admin/modules');
$response->assertStatus(200)
->assertJsonPath('data.abilities.can_install', true)
->assertJsonPath('data.abilities.can_activate', false)
->assertJsonPath('data.abilities.can_uninstall', false);
}
// ========================================================================
// 파일 업로드 설치 테스트 (installFromFile)
// ========================================================================
/**
* 인증 없이 파일 업로드 설치 시 401 반환
*/
public function test_install_from_file_returns_401_without_authentication(): void
{
$response = $this->postJson('/api/admin/modules/install-from-file', []);
$response->assertStatus(401);
}
/**
* 파일 없이 업로드 설치 요청 시 422 반환
*/
public function test_install_from_file_returns_422_without_file(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['file']);
}
/**
* 잘못된 파일 형식으로 업로드 시 422 반환
*/
public function test_install_from_file_returns_422_for_invalid_file_type(): void
{
Storage::fake('local');
$file = UploadedFile::fake()->create('document.pdf', 100);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['file']);
}
/**
* 파일 크기 초과 시 422 반환
*/
public function test_install_from_file_returns_422_for_oversized_file(): void
{
Storage::fake('local');
// 설정된 최대 크기보다 큰 파일 생성 (기본 50MB)
$maxSize = config('module.upload_max_size', 50);
$oversizedKb = ($maxSize * 1024) + 100; // 100KB 초과
$file = UploadedFile::fake()->create('module.zip', $oversizedKb);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['file']);
}
// ========================================================================
// GitHub 설치 테스트 (installFromGithub)
// ========================================================================
/**
* 인증 없이 GitHub 설치 시 401 반환
*/
public function test_install_from_github_returns_401_without_authentication(): void
{
$response = $this->postJson('/api/admin/modules/install-from-github', []);
$response->assertStatus(401);
}
/**
* GitHub URL 없이 설치 요청 시 422 반환
*/
public function test_install_from_github_returns_422_without_url(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* 잘못된 GitHub URL 형식으로 요청 시 422 반환
*/
public function test_install_from_github_returns_422_for_invalid_url(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'not-a-valid-url',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* GitHub이 아닌 URL로 요청 시 422 반환
*/
public function test_install_from_github_returns_422_for_non_github_url(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://gitlab.com/vendor/module',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['github_url']);
}
/**
* 유효한 GitHub URL 형식 테스트
*/
public function test_install_from_github_accepts_valid_github_url(): void
{
// 실제 설치는 외부 의존성이 필요하므로 URL 형식 검증만 테스트
// 실제 설치 로직은 mock이나 통합 테스트에서 수행
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
]);
// URL 형식 검증 통과 확인: 422가 아니거나 github_url 관련 validation 에러가 없어야 함
// 실제 설치 과정에서 500 에러가 발생할 수 있음 (저장소 미존재 등)
if ($response->status() === 422) {
// 422인 경우 github_url 검증 에러가 아닌지 확인
$response->assertJsonMissingValidationErrors(['github_url']);
} else {
// 422가 아니면 URL 형식은 통과한 것
$this->assertContains($response->status(), [200, 201, 500]);
}
}
// ========================================================================
// 파일 업로드 설치 - Service Mock 테스트
// ========================================================================
/**
* 유효한 ZIP 파일로 모듈 설치 성공 (Service Mock)
*/
public function test_install_from_file_succeeds_with_valid_zip(): void
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andReturn([
'identifier' => 'sirsoft-testmodule',
'name' => 'Test Module',
'version' => '1.0.0',
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(201)
->assertJson(['success' => true])
->assertJsonPath('data.identifier', 'sirsoft-testmodule');
}
/**
* ModuleService에서 RuntimeException 발생 시 422 반환
*/
public function test_install_from_file_returns_422_on_runtime_exception(): void
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \RuntimeException('module.json을 찾을 수 없습니다.'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'module.json을 찾을 수 없습니다.');
}
/**
* ModuleService에서 일반 Exception 발생 시 500 반환
*/
public function test_install_from_file_returns_500_on_general_exception(): void
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromZipFile')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
$response = $this->authRequest()->postJson('/api/admin/modules/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
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
->with('https://github.com/sirsoft/sample-module')
->andReturn([
'identifier' => 'sirsoft-samplemodule',
'name' => 'Sample Module',
'version' => '1.0.0',
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
]);
$response->assertStatus(201)
->assertJson(['success' => true])
->assertJsonPath('data.identifier', 'sirsoft-samplemodule');
}
/**
* ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub)
*/
public function test_install_from_github_returns_422_on_runtime_exception(): void
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \RuntimeException('GitHub 저장소를 찾을 수 없습니다.'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'GitHub 저장소를 찾을 수 없습니다.');
}
/**
* ModuleService에서 일반 Exception 발생 시 500 반환 (GitHub)
*/
public function test_install_from_github_returns_500_on_general_exception(): void
{
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('installFromGithub')
->once()
->andThrow(new \Exception('예상치 못한 오류'));
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
'github_url' => 'https://github.com/sirsoft/sample-module',
]);
$response->assertStatus(500);
}
// ========================================================================
// 권한 테스트
// ========================================================================
/**
* create 권한 없는 관리자가 모듈 설치 시도 시 403 반환
*
* 이 테스트는 setUp에서 생성된 admin 역할에 core.modules.create 권한이 이미 연결되어 있으므로,
* admin 역할에서 create 권한을 분리하여 테스트합니다.
*/
public function test_install_returns_403_without_create_permission(): void
{
// 새 사용자 생성
$user = User::factory()->create();
// admin 역할 가져오기 (setUp에서 이미 생성됨)
$adminRole = Role::where('identifier', 'admin')->first();
// admin 역할에서 create 권한 분리 (테스트용)
$createPermission = Permission::where('identifier', 'core.modules.create')->first();
if ($adminRole && $createPermission) {
$adminRole->permissions()->detach($createPermission->id);
}
// 사용자에게 admin 역할 할당
$user->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
$token = $user->createToken('test-token')->plainTextToken;
Storage::fake('local');
// 유효한 ZIP 파일 생성 (mimes:zip validation 통과)
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->postJson('/api/admin/modules/install-from-file', [
'file' => $file,
]);
$response->assertStatus(403);
}
// ========================================================================
// 활성화 의존성 체크 테스트
// ========================================================================
/**
* 모듈 활성화 시 필요한 의존성이 충족되지 않으면 409 경고 응답
*/
public function test_activate_returns_409_when_dependencies_not_met(): void
{
// Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false)
->andReturn([
'success' => false,
'warning' => true,
'missing_modules' => [
['identifier' => 'sirsoft-core', 'name' => 'Core Module', 'status' => 'inactive'],
],
'missing_plugins' => [
['identifier' => 'sirsoft-payment', 'name' => 'Payment Plugin', 'status' => 'not_installed'],
],
'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
'module_name' => 'test-module',
]);
// 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-core', $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: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', true)
->andReturn([
'success' => true,
'module_info' => [
'identifier' => 'test-module',
'name' => 'Test Module',
'status' => 'active',
],
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
'module_name' => 'test-module',
'force' => true,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['success' => true]);
}
/**
* 모듈 비활성화 시 의존하는 확장이 있으면 409 경고 응답
*/
public function test_deactivate_returns_409_when_dependents_exist(): void
{
// Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false)
->andReturn([
'success' => false,
'warning' => true,
'dependent_templates' => ['sirsoft-admin_basic'],
'dependent_modules' => [
['identifier' => 'sirsoft-ecommerce', 'name' => 'Ecommerce Module'],
],
'dependent_plugins' => [],
'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.',
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
'module_name' => 'test-module',
]);
// Assert
$response->assertStatus(409)
->assertJsonStructure([
'success',
'message',
'errors' => [
'warning',
'dependent_templates',
'dependent_modules',
],
])
->assertJsonFragment(['warning' => true]);
}
/**
* 모듈 비활성화 시 force=true로 의존성 경고 무시하고 강제 비활성화
*/
public function test_deactivate_with_force_bypasses_dependent_check(): void
{
// Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', true)
->andReturn([
'success' => true,
'module_info' => [
'identifier' => 'test-module',
'name' => 'Test Module',
'status' => 'inactive',
],
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
'module_name' => 'test-module',
'force' => true,
]);
// Assert
$response->assertStatus(200)
->assertJsonFragment(['success' => true]);
}
// ========================================================================
// 에셋 정보 포함 테스트
// ========================================================================
/**
* 모듈 활성화 응답에 assets 정보가 포함됨
*/
public function test_activate_response_includes_assets_when_module_has_assets(): void
{
// Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('activateModule')
->with('test-module', false)
->andReturn([
'success' => true,
'module_info' => [
'identifier' => 'test-module',
'name' => 'Test Module',
'status' => 'active',
'assets' => [
'js' => '/api/modules/assets/test-module/dist/js/module.iife.js',
'css' => '/api/modules/assets/test-module/dist/css/module.css',
'priority' => 100,
],
],
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
'module_name' => 'test-module',
]);
// Assert
$response->assertStatus(200)
->assertJsonPath('data.assets.js', '/api/modules/assets/test-module/dist/js/module.iife.js')
->assertJsonPath('data.assets.css', '/api/modules/assets/test-module/dist/css/module.css')
->assertJsonPath('data.assets.priority', 100);
}
/**
* 모듈 비활성화 응답에 assets 정보가 포함됨
*/
public function test_deactivate_response_includes_assets_when_module_has_assets(): void
{
// Arrange: ModuleService를 Mock
$moduleServiceMock = \Mockery::mock(\App\Services\ModuleService::class);
$moduleServiceMock->shouldReceive('deactivateModule')
->with('test-module', false)
->andReturn([
'success' => true,
'module_info' => [
'identifier' => 'test-module',
'name' => 'Test Module',
'status' => 'inactive',
'assets' => [
'js' => '/api/modules/assets/test-module/dist/js/module.iife.js',
'css' => '/api/modules/assets/test-module/dist/css/module.css',
'priority' => 100,
],
],
]);
$this->app->instance(\App\Services\ModuleService::class, $moduleServiceMock);
// Act
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
'module_name' => 'test-module',
]);
// Assert
$response->assertStatus(200)
->assertJsonPath('data.assets.js', '/api/modules/assets/test-module/dist/js/module.iife.js')
->assertJsonPath('data.assets.css', '/api/modules/assets/test-module/dist/css/module.css')
->assertJsonPath('data.assets.priority', 100);
}
// ========================================================================
// Changelog 조회 테스트
// ========================================================================
/**
* CHANGELOG.md가 있는 모듈의 changelog를 조회합니다.
*/
public function test_changelog_returns_parsed_entries(): void
{
// Arrange: 테스트용 CHANGELOG.md 생성
$modulePath = base_path('modules/test-changelog-mod');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog');
$response->assertStatus(200)
->assertJsonPath('data.changelog.0.version', '0.1.1')
->assertJsonPath('data.changelog.0.categories.0.name', 'Added')
->assertJsonPath('data.changelog.1.version', '0.1.0');
} finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath);
}
}
/**
* CHANGELOG.md가 없는 모듈은 빈 배열을 반환합니다.
*/
public function test_changelog_returns_empty_for_missing_file(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules/nonexistent-module/changelog');
$response->assertStatus(200)
->assertJsonPath('data.changelog', []);
}
/**
* 버전 범위 필터링이 동작합니다.
*/
public function test_changelog_with_version_range(): void
{
$modulePath = base_path('modules/test-changelog-range');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2');
$response->assertStatus(200);
$changelog = $response->json('data.changelog');
$this->assertCount(2, $changelog);
$this->assertSame('0.1.2', $changelog[0]['version']);
$this->assertSame('0.1.1', $changelog[1]['version']);
} finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath);
}
}
/**
* 인증 없이 changelog 조회 시 401 반환
*/
public function test_changelog_returns_401_without_authentication(): void
{
$response = $this->getJson('/api/admin/modules/test-module/changelog');
$response->assertStatus(401);
}
// ========================================================================
// 라이선스 테스트
// ========================================================================
/**
* LICENSE 파일이 있는 모듈의 라이선스를 반환합니다.
*/
public function test_license_returns_content(): void
{
$modulePath = base_path('modules/test-license-mod');
\Illuminate\Support\Facades\File::ensureDirectoryExists($modulePath);
\Illuminate\Support\Facades\File::put($modulePath.'/LICENSE', 'MIT License - Test');
try {
$response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license');
$response->assertStatus(200)
->assertJsonPath('data.content', 'MIT License - Test');
} finally {
\Illuminate\Support\Facades\File::deleteDirectory($modulePath);
}
}
/**
* LICENSE 파일이 없는 모듈은 404를 반환합니다.
*/
public function test_license_returns_404_for_missing_file(): void
{
$response = $this->authRequest()->getJson('/api/admin/modules/nonexistent-module/license');
$response->assertStatus(404);
}
/**
* _bundled 디렉토리의 LICENSE 파일도 반환합니다.
*/
public function test_license_falls_back_to_bundled(): void
{
// sirsoft-board는 _bundled에 있으므로 활성 디렉토리에 없을 수 있지만 _bundled에서 찾음
$bundledPath = base_path('modules/_bundled/sirsoft-board/LICENSE');
if (file_exists($bundledPath)) {
$response = $this->authRequest()->getJson('/api/admin/modules/sirsoft-board/license');
$response->assertStatus(200)
->assertJsonStructure(['data' => ['content']]);
} else {
$this->markTestSkipped('sirsoft-board LICENSE not found in _bundled');
}
}
// ========================================================================
// 식별자 형식 검증 테스트
// ========================================================================
/**
* 잘못된 형식의 모듈 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_invalid_identifier_format(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
'module_name' => 'sirsoftboard', // 하이픈 없음
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['module_name']);
}
/**
* 숫자로 시작하는 단어가 포함된 모듈 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_digit_starting_identifier(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
'module_name' => 'sirsoft-2shop',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['module_name']);
}
/**
* 대문자가 포함된 모듈 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_uppercase_identifier(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
'module_name' => 'Sirsoft-Board',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['module_name']);
}
/**
* 특수문자가 포함된 모듈 식별자로 설치 요청 시 422 반환
*/
public function test_install_returns_422_for_special_char_identifier(): void
{
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
'module_name' => 'sirsoft-my@module',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['module_name']);
}
}