seed(\Database\Seeders\RolePermissionSeeder::class); $this->reader = $this->makeUserWithPermissions(['core.language_packs.read']); $this->manager = $this->makeUserWithPermissions([ 'core.language_packs.read', 'core.language_packs.install', 'core.language_packs.manage', 'core.language_packs.update', ]); $this->stranger = User::factory()->create(); } /** * 지정된 권한을 가진 사용자를 생성합니다. * * @param array $permissionIdentifiers 권한 식별자 목록 * @return User 생성된 사용자 */ private function makeUserWithPermissions(array $permissionIdentifiers): User { $user = User::factory()->create(); $role = Role::query()->create([ 'identifier' => 'lp_test_role_'.uniqid(), 'name' => ['ko' => '테스트', 'en' => 'Test'], 'description' => ['ko' => '', 'en' => ''], 'is_active' => true, ]); $permissions = Permission::query() ->whereIn('identifier', $permissionIdentifiers) ->get(); $role->permissions()->attach($permissions->pluck('id')->all()); $user->roles()->attach($role->id); return $user; } /** * 활성 코어 ja 언어팩을 생성합니다. * * @return LanguagePack 생성된 언어팩 */ private function makeActiveCorePack(): LanguagePack { return LanguagePack::query()->create([ 'identifier' => 'sirsoft-core-ja', 'vendor' => 'sirsoft', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Active->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); } public function test_index_returns_paginated_list(): void { $this->makeActiveCorePack(); Sanctum::actingAs($this->reader); // exclude_protected=1 로 가상 보호 행(built_in) 제외 — DB 활성 코어 1건만 검증. $response = $this->getJson('/api/admin/language-packs?status=active&exclude_protected=1'); $response->assertOk() ->assertJsonPath('data.data.0.identifier', 'sirsoft-core-ja') ->assertJsonPath('data.meta.total', 1); } public function test_index_filter_by_scope(): void { $this->makeActiveCorePack(); LanguagePack::query()->create([ 'identifier' => 'acme-template-foo-ja', 'vendor' => 'acme', 'scope' => LanguagePackScope::Template->value, 'target_identifier' => 'foo', 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Active->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->reader); // scope=core + status=active + exclude_protected → DB 활성 코어 1건만 (built_in 가상 행 제외). $response = $this->getJson('/api/admin/language-packs?scope=core&status=active&exclude_protected=1'); $response->assertOk() ->assertJsonCount(1, 'data.data'); } public function test_index_includes_uninstalled_bundled_rows_with_install_ability(): void { // 임시 번들 디렉토리 — 본 테스트의 슬롯 충돌만 회피. $bundledIdentifier = 'test-feature-acme-zz-'.uniqid(); $bundledPath = base_path('lang-packs/_bundled/'.$bundledIdentifier); \Illuminate\Support\Facades\File::ensureDirectoryExists($bundledPath); \Illuminate\Support\Facades\File::put( $bundledPath.'/language-pack.json', json_encode([ 'identifier' => $bundledIdentifier, 'vendor' => 'acme', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'zz', 'locale_name' => 'Zzland', 'locale_native_name' => 'Zzland', 'text_direction' => 'ltr', 'version' => '0.1.0', ], JSON_UNESCAPED_UNICODE) ); try { Sanctum::actingAs($this->manager); $response = $this->getJson('/api/admin/language-packs?status=uninstalled&search='.$bundledIdentifier); $response->assertOk(); $rows = $response->json('data.data'); $this->assertNotEmpty($rows, '미설치 번들 가상 행이 응답에 포함되어야 함'); $first = $rows[0]; $this->assertSame($bundledIdentifier, $first['identifier']); $this->assertSame('uninstalled', $first['status']); $this->assertSame($bundledIdentifier, $first['bundled_identifier']); $this->assertNull($first['id']); $this->assertTrue($first['abilities']['can_install'] ?? false); } finally { \Illuminate\Support\Facades\File::deleteDirectory($bundledPath); } } public function test_install_from_bundled_promotes_uninstalled_row(): void { // 검증기는 core 스코프에서 identifier 가 `{vendor}-core-{locale}` 형식이어야 한다고 요구. // 테스트 격리를 위해 vendor 에 uniqid 를 포함해 재실행 안전성을 확보한다. $vendor = 'acme'.substr(uniqid(), -6); $locale = 'yy'; $bundledIdentifier = $vendor.'-core-'.$locale; $bundledPath = base_path('lang-packs/_bundled/'.$bundledIdentifier); \Illuminate\Support\Facades\File::ensureDirectoryExists($bundledPath); \Illuminate\Support\Facades\File::put( $bundledPath.'/language-pack.json', json_encode([ 'identifier' => $bundledIdentifier, 'namespace' => $vendor, 'vendor' => $vendor, 'name' => ['ko' => 'Test Language Pack', 'en' => 'Test Language Pack'], 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => $locale, 'locale_name' => 'Yyland', 'locale_native_name' => 'Yyland', 'text_direction' => 'ltr', 'version' => '0.1.0', 'g7_version' => '>=1.0.0', ], JSON_UNESCAPED_UNICODE) ); $installedDir = base_path('lang-packs/'.$bundledIdentifier); try { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/install-from-bundled', [ 'identifier' => $bundledIdentifier, 'auto_activate' => true, ]); $response->assertStatus(201) ->assertJsonPath('data.identifier', $bundledIdentifier) ->assertJsonPath('data.status', 'active') ->assertJsonPath('data.source_type', 'bundled'); $this->assertDatabaseHas('language_packs', [ 'identifier' => $bundledIdentifier, 'status' => LanguagePackStatus::Active->value, ]); } finally { \Illuminate\Support\Facades\File::deleteDirectory($bundledPath); \Illuminate\Support\Facades\File::deleteDirectory($installedDir); } } public function test_show_returns_pack_details(): void { $pack = $this->makeActiveCorePack(); Sanctum::actingAs($this->reader); $response = $this->getJson('/api/admin/language-packs/'.$pack->id); $response->assertOk() ->assertJsonPath('data.identifier', 'sirsoft-core-ja') ->assertJsonStructure(['data' => ['manifest']]); } public function test_show_404_for_missing_pack(): void { Sanctum::actingAs($this->reader); $response = $this->getJson('/api/admin/language-packs/999999'); $response->assertStatus(404); } public function test_install_from_file_rejects_oversize(): void { Sanctum::actingAs($this->manager); $bigContent = str_repeat('x', 11 * 1024 * 1024); $response = $this->postJson('/api/admin/language-packs/install-from-file', [ 'file' => \Illuminate\Http\UploadedFile::fake()->createWithContent('big.zip', $bigContent), ]); $response->assertStatus(422); } public function test_install_from_github_rejects_invalid_url(): void { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/install-from-github', [ 'github_url' => 'https://gitlab.com/foo/bar', ]); $response->assertStatus(422); } public function test_install_from_url_rejects_invalid_checksum(): void { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/install-from-url', [ 'url' => 'https://example.com/pack.zip', 'checksum' => 'invalid', ]); $response->assertStatus(422); } public function test_activate_promotes_inactive_pack(): void { $pack = LanguagePack::query()->create([ 'identifier' => 'sirsoft-core-ja', 'vendor' => 'sirsoft', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Inactive->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/'.$pack->id.'/activate'); $response->assertOk(); $this->assertSame('active', $pack->fresh()->status); } public function test_activate_returns_409_when_slot_already_active(): void { // 슬롯에 이미 active 인 코어/ja 팩 + 같은 슬롯 후보 (inactive) $current = $this->makeActiveCorePack(); $candidate = LanguagePack::query()->create([ 'identifier' => 'gnuboard-core-ja', 'vendor' => 'gnuboard', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Inactive->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/'.$candidate->id.'/activate'); $response->assertStatus(409) ->assertJsonPath('errors.current.identifier', $current->identifier) ->assertJsonPath('errors.target.identifier', $candidate->identifier); // 양쪽 상태가 보존되었는지 확인 (force 미전달이면 변경 없어야 함) $this->assertSame('active', $current->fresh()->status); $this->assertSame('inactive', $candidate->fresh()->status); } public function test_activate_with_force_replaces_active_pack(): void { $current = $this->makeActiveCorePack(); $candidate = LanguagePack::query()->create([ 'identifier' => 'gnuboard-core-ja', 'vendor' => 'gnuboard', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Inactive->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/'.$candidate->id.'/activate', [ 'force' => true, ]); $response->assertOk(); $this->assertSame('inactive', $current->fresh()->status); $this->assertSame('active', $candidate->fresh()->status); } public function test_deactivate_changes_status(): void { $pack = $this->makeActiveCorePack(); Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/'.$pack->id.'/deactivate'); $response->assertOk(); $this->assertSame('inactive', $pack->fresh()->status); } public function test_uninstall_removes_pack(): void { $pack = LanguagePack::query()->create([ 'identifier' => 'sirsoft-core-ja', 'vendor' => 'sirsoft', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Inactive->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->manager); $response = $this->deleteJson('/api/admin/language-packs/'.$pack->id); $response->assertOk(); $this->assertNull(LanguagePack::query()->find($pack->id)); } public function test_uninstall_protected_pack_returns_500(): void { $pack = LanguagePack::query()->create([ 'identifier' => 'g7-core-ko', 'vendor' => 'g7', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ko', 'locale_name' => 'Korean', 'locale_native_name' => '한국어', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Active->value, 'is_protected' => true, 'manifest' => [], ]); Sanctum::actingAs($this->manager); $response = $this->deleteJson('/api/admin/language-packs/'.$pack->id); $response->assertStatus(500); } public function test_unauthorized_user_cannot_access(): void { Sanctum::actingAs($this->stranger); $response = $this->getJson('/api/admin/language-packs'); $response->assertStatus(403); } public function test_reader_cannot_install(): void { Sanctum::actingAs($this->reader); $response = $this->postJson('/api/admin/language-packs/install-from-github', [ 'github_url' => 'https://github.com/foo/bar', ]); $response->assertStatus(403); } public function test_reader_cannot_activate(): void { $pack = LanguagePack::query()->create([ 'identifier' => 'sirsoft-core-ja', 'vendor' => 'sirsoft', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'ja', 'locale_name' => 'Japanese', 'locale_native_name' => '日本語', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Inactive->value, 'is_protected' => false, 'manifest' => [], ]); Sanctum::actingAs($this->reader); $response = $this->postJson('/api/admin/language-packs/'.$pack->id.'/activate'); $response->assertStatus(403); } public function test_check_updates_returns_summary(): void { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/check-updates'); $response->assertOk() ->assertJsonPath('data.checked', 0) ->assertJsonPath('data.updates', 0); } public function test_check_updates_requires_update_permission(): void { Sanctum::actingAs($this->reader); $response = $this->postJson('/api/admin/language-packs/check-updates'); $response->assertStatus(403); } public function test_perform_update_rejects_non_github_source(): void { $pack = $this->makeActiveCorePack(); Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/'.$pack->id.'/update'); $response->assertStatus(500); } public function test_perform_update_404_for_missing_pack(): void { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/9999999/update'); $response->assertStatus(404); } public function test_refresh_cache_returns_status_map(): void { Sanctum::actingAs($this->manager); $response = $this->postJson('/api/admin/language-packs/refresh-cache'); $response->assertOk() ->assertJsonStructure(['data' => ['registry', 'translator', 'template']]); } public function test_changelog_returns_empty_when_missing(): void { $pack = $this->makeActiveCorePack(); Sanctum::actingAs($this->manager); $response = $this->getJson('/api/admin/language-packs/'.$pack->id.'/changelog'); $response->assertOk() ->assertJsonPath('data.has_changelog', false); } public function test_index_search_filter(): void { $this->makeActiveCorePack(); LanguagePack::query()->create([ 'identifier' => 'foobar-core-fr', 'vendor' => 'foobar', 'scope' => LanguagePackScope::Core->value, 'target_identifier' => null, 'locale' => 'fr', 'locale_name' => 'French', 'locale_native_name' => 'Français', 'text_direction' => 'ltr', 'version' => '1.0.0', 'status' => LanguagePackStatus::Active->value, 'is_protected' => false, 'manifest' => [], 'source_type' => 'zip', ]); Sanctum::actingAs($this->manager); $response = $this->getJson('/api/admin/language-packs?search=foobar'); $response->assertOk() ->assertJsonCount(1, 'data.data') ->assertJsonPath('data.data.0.vendor', 'foobar'); } }