v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -97,7 +97,7 @@ class ClearsTemplateCachesTest extends TestCase
*/
public function test_get_extension_cache_version_returns_zero_when_not_set(): void
{
Cache::forget('extension_cache_version');
Cache::forget('g7:core:ext.cache_version');
$version = ClearsTemplateCaches::getExtensionCacheVersion();
@@ -110,7 +110,7 @@ class ClearsTemplateCachesTest extends TestCase
public function test_get_extension_cache_version_returns_cached_value(): void
{
$expectedVersion = 1735000000;
Cache::put('extension_cache_version', $expectedVersion);
Cache::put('g7:core:ext.cache_version', $expectedVersion);
$version = ClearsTemplateCaches::getExtensionCacheVersion();
@@ -118,11 +118,17 @@ class ClearsTemplateCachesTest extends TestCase
}
/**
* 템플릿 언어 캐시 무효화 테스트
* clearAllTemplateLanguageCaches() 는 버전 기반 설계로 리팩토링되어 레거시 no-op.
*
* 2026-03-31 커밋 c86c6d3e5 에서 실제 캐시 삭제 로직 제거. 대신
* incrementExtensionCacheVersion() 으로 버전 카운터가 증가하고,
* 프론트엔드는 새 버전의 캐시 키로 API를 재요청하여 실질적 무효화가 이루어짐.
* 이전 버전 캐시는 TTL 로 자연 만료.
*
* 본 테스트는 현재 동작(호출 호환성 유지, 버전 카운터 증가)을 검증한다.
*/
public function test_clear_all_template_language_caches(): void
public function test_clear_all_template_language_caches_is_no_op_but_triggers_version_bump(): void
{
// 활성 템플릿 생성 - 고유 identifier 사용하여 트랜잭션 락 충돌 방지
$identifier = 'test-template-'.uniqid();
$template = Template::create([
'identifier' => $identifier,
@@ -134,30 +140,24 @@ class ClearsTemplateCachesTest extends TestCase
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 언어 캐시 설정
Cache::put("template.language.{$template->identifier}.ko", ['key' => 'value']);
Cache::put("template.language.{$template->identifier}.en", ['key' => 'value']);
// 캐시 존재 확인
$this->assertTrue(Cache::has("template.language.{$template->identifier}.ko"));
$this->assertTrue(Cache::has("template.language.{$template->identifier}.en"));
// 캐시 무효화
// clearAllTemplateLanguageCaches 자체는 no-op (호출 호환성 유지용)
// 예외 없이 호출 가능해야 함
$this->traitUser->callClearAllTemplateLanguageCaches();
// 캐시가 삭제됨
$this->assertFalse(Cache::has("template.language.{$template->identifier}.ko"));
$this->assertFalse(Cache::has("template.language.{$template->identifier}.en"));
// 기존 캐시는 그대로 유지 — 버전 기반 무효화는 프론트엔드가 새 버전 키로 요청하여 처리
$this->assertTrue(
Cache::has("template.language.{$template->identifier}.ko"),
'clearAllTemplateLanguageCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
);
}
/**
* 템플릿 routes 캐시 무효화 테스트
*/
public function test_clear_all_template_routes_caches(): void
public function test_clear_all_template_routes_caches_is_no_op(): void
{
// 활성 템플릿 생성 - 고유 identifier 사용하여 트랜잭션 락 충돌 방지
$identifier = 'test-template-'.uniqid();
$template = Template::create([
Template::create([
'identifier' => $identifier,
'vendor' => 'test',
'name' => ['ko' => '테스트', 'en' => 'Test'],
@@ -167,26 +167,30 @@ class ClearsTemplateCachesTest extends TestCase
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// routes 캐시 설정
Cache::put("template.routes.{$template->identifier}", ['routes' => []]);
Cache::put("template.routes.{$identifier}", ['routes' => []]);
$this->assertTrue(Cache::has("template.routes.{$identifier}"));
// 캐시 존재 확인
$this->assertTrue(Cache::has("template.routes.{$template->identifier}"));
// 캐시 무효화
// no-op 호출 — 예외 없이 성공해야 함
$this->traitUser->callClearAllTemplateRoutesCaches();
// 캐시가 삭제됨
$this->assertFalse(Cache::has("template.routes.{$template->identifier}"));
// 레거시 캐시는 유지됨
$this->assertTrue(
Cache::has("template.routes.{$identifier}"),
'clearAllTemplateRoutesCaches 는 레거시 캐시를 직접 삭제하지 않아야 함'
);
}
/**
* 비활성 템플릿의 캐시는 무효화하지 않음
* incrementExtensionCacheVersion 호출 후 getExtensionCacheVersion 이 증가된 타임스탬프 반환.
*
* 활성/비활성 템플릿 구분은 더 이상 trait 수준에서 다루지 않으며
* (레거시 테스트가 기대한 "활성만 삭제" 로직은 제거되었음),
* 실제 무효화는 모든 프론트엔드 요청이 새 버전 파라미터를 사용하게 함으로써 이루어진다.
*/
public function test_clear_caches_only_affects_active_templates(): void
public function test_increment_extension_cache_version_is_actual_invalidation_mechanism(): void
{
// 활성 템플릿
$activeTemplate = Template::create([
// 활성/비활성 템플릿 생성
Template::create([
'identifier' => 'active-template',
'vendor' => 'test',
'name' => ['ko' => '활성', 'en' => 'Active'],
@@ -195,9 +199,7 @@ class ClearsTemplateCachesTest extends TestCase
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 비활성 템플릿
$inactiveTemplate = Template::create([
Template::create([
'identifier' => 'inactive-template',
'vendor' => 'test',
'name' => ['ko' => '비활성', 'en' => 'Inactive'],
@@ -207,16 +209,15 @@ class ClearsTemplateCachesTest extends TestCase
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
// 두 템플릿의 언어 캐시 설정
Cache::put("template.language.{$activeTemplate->identifier}.ko", ['key' => 'active']);
Cache::put("template.language.{$inactiveTemplate->identifier}.ko", ['key' => 'inactive']);
// 초기 버전 0
$this->assertEquals(0, \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion());
// 캐시 무효화
$this->traitUser->callClearAllTemplateLanguageCaches();
// 버전 증가
$this->traitUser->callIncrementExtensionCacheVersion();
// 활성 템플릿 캐시만 삭제됨
$this->assertFalse(Cache::has("template.language.{$activeTemplate->identifier}.ko"));
// 비활성 템플릿 캐시는 유지됨
$this->assertTrue(Cache::has("template.language.{$inactiveTemplate->identifier}.ko"));
// 타임스탬프로 설정됨 (양수)
$newVersion = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
$this->assertGreaterThan(0, $newVersion);
$this->assertLessThanOrEqual(time(), $newVersion);
}
}
@@ -0,0 +1,76 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\CoreVersionChecker;
use Tests\TestCase;
/**
* CoreVersionChecker::getCoreVersion() env 우선 읽기 회귀 테스트.
*
* 코어 업그레이드 중 spawn 프로세스(proc_open)가 `APP_VERSION={toVersion}` env 를 전달하지만
* `bootstrap/cache/config.php` 가 존재하면 `LoadConfiguration` 이 캐시된 리터럴을 사용해
* env 오버라이드가 반영되지 않는 회귀가 있었다. 본 테스트는 `getCoreVersion()` 이
* env 를 우선 읽어 config cache 유무와 무관하게 spawn 이 전달한 버전을 신뢰함을 검증한다.
*/
class CoreVersionCheckerEnvPriorityTest extends TestCase
{
private ?string $originalEnvVersion = null;
protected function setUp(): void
{
parent::setUp();
$this->originalEnvVersion = $_ENV['APP_VERSION'] ?? null;
}
protected function tearDown(): void
{
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
if ($this->originalEnvVersion !== null) {
$_ENV['APP_VERSION'] = $this->originalEnvVersion;
$_SERVER['APP_VERSION'] = $this->originalEnvVersion;
putenv('APP_VERSION='.$this->originalEnvVersion);
} else {
putenv('APP_VERSION');
}
parent::tearDown();
}
public function test_env_app_version_overrides_config(): void
{
config()->set('app.version', '7.0.0-beta.1'); // 캐시된 stale 값 시뮬레이션
$_ENV['APP_VERSION'] = '7.0.0-beta.2'; // spawn 이 전달한 toVersion
$this->assertSame(
'7.0.0-beta.2',
CoreVersionChecker::getCoreVersion(),
'env APP_VERSION 이 config 보다 우선해야 합니다.'
);
}
public function test_falls_back_to_config_when_env_missing(): void
{
unset($_ENV['APP_VERSION'], $_SERVER['APP_VERSION']);
putenv('APP_VERSION');
config()->set('app.version', '7.0.0-beta.1');
$this->assertSame(
'7.0.0-beta.1',
CoreVersionChecker::getCoreVersion(),
'env 가 없으면 config 값을 반환해야 합니다.'
);
}
public function test_spawn_scenario_cached_config_with_env_override(): void
{
// spawn 환경 시뮬레이션: config 는 fromVersion 으로 캐시, env 는 toVersion
config()->set('app.version', '7.0.0-beta.1');
$_ENV['APP_VERSION'] = '7.0.0-beta.2';
// 확장이 >=7.0.0-beta.2 요구 — env 기반 판정이어야 호환성 통과
$this->assertTrue(
CoreVersionChecker::isCompatible('>=7.0.0-beta.2'),
'spawn 에서 env 로 전달된 toVersion 기준으로 호환성이 평가되어 확장이 자동 비활성화되지 않아야 합니다.'
);
}
}
@@ -0,0 +1,210 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\ExtensionManager;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* ExtensionManager 의 외부 ZIP 추출/manifest 검증 단위 테스트
*
* - extractFromZip(): 평탄 루트 / wrapper 디렉토리 구조 모두 지원
* - prepareZipSource(): manifest 존재/해석/identifier/version 검증 경로
*/
class ExtensionManagerZipTest extends TestCase
{
private ExtensionManager $extensionManager;
/** @var array<string> 테스트 종료 시 정리할 임시 디렉토리 목록 */
private array $tempDirs = [];
protected function setUp(): void
{
parent::setUp();
$this->extensionManager = app(ExtensionManager::class);
}
protected function tearDown(): void
{
foreach ($this->tempDirs as $dir) {
if (File::isDirectory($dir)) {
File::deleteDirectory($dir);
}
}
parent::tearDown();
}
/**
* 루트 레벨에 manifest 가 있는 평탄 ZIP 을 추출하고 그 디렉토리를 반환하는지 검증합니다.
*/
public function test_extract_from_zip_returns_extract_dir_for_flat_layout(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('flat');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'flat.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('module.json', '{"identifier":"sirsoft-example","version":"1.0.0"}');
$zip->addFromString('composer.json', '{}');
$zip->close();
$destDir = $this->makeTempDir('flat_dest');
$extractedDir = $this->extensionManager->extractFromZip($zipPath, $destDir);
$this->assertSame($destDir.DIRECTORY_SEPARATOR.'extracted', $extractedDir);
$this->assertTrue(File::exists($extractedDir.DIRECTORY_SEPARATOR.'module.json'));
}
/**
* owner-repo-hash/ 래퍼 디렉토리가 1개만 있는 ZIP 은 그 내부를 source 로 반환하는지 검증합니다.
*/
public function test_extract_from_zip_unwraps_single_wrapper_directory(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('wrap');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'wrap.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('sirsoft-example-abc123/module.json', '{"identifier":"sirsoft-example","version":"1.0.0"}');
$zip->addFromString('sirsoft-example-abc123/README.md', '# example');
$zip->close();
$destDir = $this->makeTempDir('wrap_dest');
$extractedDir = $this->extensionManager->extractFromZip($zipPath, $destDir);
$expected = $destDir.DIRECTORY_SEPARATOR.'extracted'.DIRECTORY_SEPARATOR.'sirsoft-example-abc123';
$this->assertSame(realpath($expected), realpath($extractedDir));
$this->assertTrue(File::exists($extractedDir.DIRECTORY_SEPARATOR.'module.json'));
}
/**
* ZIP 파일이 존재하지 않으면 RuntimeException 이 발생하는지 검증합니다.
*/
public function test_extract_from_zip_throws_when_zip_missing(): void
{
$destDir = $this->makeTempDir('missing_dest');
$this->expectException(\RuntimeException::class);
$this->extensionManager->extractFromZip($destDir.DIRECTORY_SEPARATOR.'nonexistent.zip', $destDir);
}
/**
* prepareZipSource 가 유효한 ZIP + manifest 조합에서 version/manifest 를 반환하는지 검증합니다.
*/
public function test_prepare_zip_source_returns_version_and_manifest(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('prep');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'ext.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('module.json', json_encode([
'identifier' => 'sirsoft-example',
'version' => '2.3.4',
'name' => 'Example',
]));
$zip->close();
$result = $this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
$this->tempDirs[] = $result['temp_dir']; // 함수 내부에서 생성한 temp_dir 정리 등록
$this->assertSame('2.3.4', $result['to_version']);
$this->assertSame('sirsoft-example', $result['manifest']['identifier']);
$this->assertTrue(File::isDirectory($result['temp_dir']));
$this->assertTrue(File::exists($result['extracted_dir'].DIRECTORY_SEPARATOR.'module.json'));
}
/**
* manifest 파일이 없는 경우 RuntimeException + 임시 디렉토리 자동 정리가 발생하는지 검증합니다.
*/
public function test_prepare_zip_source_throws_when_manifest_missing(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('no_manifest');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'no_manifest.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('README.md', '# no manifest');
$zip->close();
$this->expectException(\RuntimeException::class);
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
}
/**
* identifier 가 일치하지 않으면 RuntimeException 이 발생하는지 검증합니다.
*/
public function test_prepare_zip_source_throws_on_identifier_mismatch(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('id_mismatch');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'id_mismatch.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('module.json', json_encode([
'identifier' => 'sirsoft-other',
'version' => '1.0.0',
]));
$zip->close();
$this->expectException(\RuntimeException::class);
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
}
/**
* manifest 에 version 필드가 없으면 RuntimeException 이 발생하는지 검증합니다.
*/
public function test_prepare_zip_source_throws_when_version_missing(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = $this->makeTempDir('no_version');
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'no_version.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('module.json', json_encode([
'identifier' => 'sirsoft-example',
]));
$zip->close();
$this->expectException(\RuntimeException::class);
$this->extensionManager->prepareZipSource($zipPath, 'sirsoft-example', 'module.json');
}
/**
* 임시 디렉토리를 생성하고 tearDown 시 정리되도록 등록합니다.
*/
private function makeTempDir(string $suffix): string
{
$dir = storage_path('test_ext_zip_'.$suffix.'_'.uniqid());
File::ensureDirectoryExists($dir);
$this->tempDirs[] = $dir;
return $dir;
}
}
@@ -120,31 +120,48 @@ class ExtensionPendingHelperTest extends TestCase
/**
* JSON 없는 디렉토리를 건너뛰는지 확인합니다.
*
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
* 무관하게 동작해야 한다.
*/
public function test_load_pending_extensions_skips_invalid_dirs(): void
{
// JSON 없는 디렉토리 생성
$invalidPath = $this->modulesPath.'/_pending/invalid-mod';
File::ensureDirectoryExists($invalidPath);
$isolatedModulesPath = $this->tempBase.'/isolated-modules';
$pendingDir = $isolatedModulesPath.'/_pending';
$result = ExtensionPendingHelper::loadPendingExtensions($this->modulesPath, 'module.json');
// 유효한 pending 모듈 1개
File::ensureDirectoryExists($pendingDir.'/valid-mod');
File::put($pendingDir.'/valid-mod/module.json', json_encode([
'identifier' => 'valid-mod',
'version' => '1.0.0',
'name' => 'Valid Module',
]));
$this->assertCount(1, $result);
// JSON 없는 invalid 디렉토리
File::ensureDirectoryExists($pendingDir.'/invalid-mod');
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
$this->assertCount(1, $result, 'JSON 없는 디렉토리는 결과에 포함되지 않아야 함');
$this->assertArrayHasKey('valid-mod', $result);
$this->assertArrayNotHasKey('invalid-mod', $result);
// 정리
File::deleteDirectory($invalidPath);
}
/**
* _pending이 비어있으면 빈 배열을 반환하는지 확인합니다.
*
* 격리된 임시 modulesPath 에서 테스트 — 실제 modules/_pending 디렉토리 상태와
* 무관하게 동작해야 한다.
*/
public function test_load_pending_extensions_returns_empty_when_no_pending(): void
{
// 기존 테스트 데이터 정리
File::deleteDirectory($this->modulesPath.'/_pending/test-pending-mod');
$isolatedModulesPath = $this->tempBase.'/isolated-modules-empty';
$pendingDir = $isolatedModulesPath.'/_pending';
$result = ExtensionPendingHelper::loadPendingExtensions($this->modulesPath, 'module.json');
// _pending 디렉토리는 존재하지만 비어있음
File::ensureDirectoryExists($pendingDir);
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedModulesPath, 'module.json');
$this->assertEmpty($result);
}
@@ -160,6 +177,78 @@ class ExtensionPendingHelperTest extends TestCase
$this->assertEmpty($result);
}
/**
* 디렉토리명과 manifest 의 identifier 가 일치하지 않는 디렉토리는 스킵되어야 합니다.
*
* 업데이트/백업 과정에서 남는 임시 디렉토리(예: sirsoft-admin_basic_20260402_081819,
* sirsoft-admin_basic_updating_<uniq>, sirsoft-admin_basic_old_<uniq>)는 내부에 원본
* manifest 를 그대로 가지고 있어 identifier 가 원본과 동일하다. 이를 등록하면 install
* 시 `getPendingPath({identifier})` 가 존재하지 않는 표준 경로를 반환해 실패한다.
*/
public function test_load_extensions_skips_directories_with_mismatched_identifier(): void
{
$isolatedPath = $this->tempBase.'/isolated-mismatched';
$pendingDir = $isolatedPath.'/_pending';
// 정상 _pending 디렉토리 (디렉토리명 == identifier)
File::ensureDirectoryExists($pendingDir.'/sirsoft-board');
File::put($pendingDir.'/sirsoft-board/module.json', json_encode([
'identifier' => 'sirsoft-board',
'version' => '1.0.0',
'name' => 'Board',
]));
// 업데이트 백업 임시 디렉토리 (디렉토리명에 타임스탬프 suffix, manifest 는 원본 그대로)
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_20260402_081819');
File::put($pendingDir.'/sirsoft-board_20260402_081819/module.json', json_encode([
'identifier' => 'sirsoft-board',
'version' => '1.0.0',
'name' => 'Board',
]));
// 업데이트 진행 중 임시 디렉토리
File::ensureDirectoryExists($pendingDir.'/sirsoft-board_updating_abc123');
File::put($pendingDir.'/sirsoft-board_updating_abc123/module.json', json_encode([
'identifier' => 'sirsoft-board',
'version' => '1.0.0',
'name' => 'Board',
]));
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
$this->assertCount(1, $result, '정상 디렉토리 1개만 등록되어야 함');
$this->assertArrayHasKey('sirsoft-board', $result);
$this->assertEquals(
'sirsoft-board',
basename($result['sirsoft-board']['source_path']),
'source_path 가 표준 디렉토리여야 함 (타임스탬프 suffix 가 붙은 임시 디렉토리가 아님)'
);
}
/**
* 정상 디렉토리가 없고 임시 디렉토리만 남은 경우 빈 배열을 반환해야 합니다.
*
* 이전 업데이트가 비정상 종료되어 원본이 삭제되고 임시 디렉토리만 남는 상황.
* 이 경우 확장을 "pending" 으로 인식하면 install 이 잘못된 경로로 접근해 실패하므로,
* 빈 배열을 반환하여 _bundled 폴백으로 넘어가게 해야 한다.
*/
public function test_load_extensions_returns_empty_when_only_temp_directories_exist(): void
{
$isolatedPath = $this->tempBase.'/isolated-temp-only';
$pendingDir = $isolatedPath.'/_pending';
File::ensureDirectoryExists($pendingDir.'/orphan-mod_old_xyz');
File::put($pendingDir.'/orphan-mod_old_xyz/module.json', json_encode([
'identifier' => 'orphan-mod',
'version' => '1.0.0',
'name' => 'Orphan',
]));
$result = ExtensionPendingHelper::loadPendingExtensions($isolatedPath, 'module.json');
$this->assertEmpty($result, '임시 디렉토리만 있으면 pending 로 인식하지 않아야 함');
}
/**
* _pending에서 활성 디렉토리로 복사되는지 확인합니다.
*/
@@ -0,0 +1,141 @@
<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Models\Menu;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase C.1 (v6) 회귀 방지: helper `cleanupStale*` 가 user_overrides **무관** 으로 삭제하는지.
*
* PO 요구사항 재정의 (v6):
* - config/확장 정의에 없는 row 는 user_overrides 유무 무관 삭제
* - 필드 단위 보존은 upsert 시점(`syncMenu`/`syncRole`) 에서만 작동
* - 예외: Role 에 `user_roles` 피벗 참조 사용자 있으면 삭제 차단 (안전 가드)
*/
class ExtensionSyncHelperUserOverridesTest extends TestCase
{
use RefreshDatabase;
public function test_cleanupStaleMenus_deletes_even_with_user_overrides(): void
{
Menu::create([
'slug' => 'stale-with-overrides',
'name' => ['ko' => 'user_overrides 있어도 삭제'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'url' => '/admin/stale-overrides',
'user_overrides' => ['name', 'url'],
'is_active' => true,
'order' => 1,
]);
Menu::create([
'slug' => 'stale-without-overrides',
'name' => ['ko' => 'user_overrides 없는 삭제'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'url' => '/admin/stale',
'user_overrides' => [],
'is_active' => true,
'order' => 2,
]);
$helper = app(ExtensionMenuSyncHelper::class);
$deleted = $helper->cleanupStaleMenus(ExtensionOwnerType::Core, 'core', []);
$this->assertGreaterThanOrEqual(2, $deleted);
$this->assertDatabaseMissing('menus', ['slug' => 'stale-with-overrides']);
$this->assertDatabaseMissing('menus', ['slug' => 'stale-without-overrides']);
}
public function test_cleanupStalePermissions_deletes_by_pure_diff(): void
{
Permission::create([
'identifier' => 'core.stale.to_keep',
'name' => ['ko' => '유지'],
'description' => ['ko' => '유지'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
]);
Permission::create([
'identifier' => 'core.stale.to_delete',
'name' => ['ko' => '삭제'],
'description' => ['ko' => '삭제'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
]);
$helper = app(ExtensionRoleSyncHelper::class);
$deleted = $helper->cleanupStalePermissions(
ExtensionOwnerType::Core,
'core',
['core.stale.to_keep'],
);
$this->assertGreaterThanOrEqual(1, $deleted);
$this->assertDatabaseHas('permissions', ['identifier' => 'core.stale.to_keep']);
$this->assertDatabaseMissing('permissions', ['identifier' => 'core.stale.to_delete']);
}
public function test_cleanupStaleRoles_deletes_even_with_user_overrides(): void
{
Role::create([
'identifier' => 'test-stale-with-overrides',
'name' => ['ko' => 'user_overrides 있어도 삭제'],
'description' => ['ko' => '삭제됨'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
'user_overrides' => ['name'],
]);
Role::create([
'identifier' => 'test-stale-no-overrides',
'name' => ['ko' => 'user_overrides 없이 삭제'],
'description' => ['ko' => '삭제됨'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
'user_overrides' => [],
]);
$helper = app(ExtensionRoleSyncHelper::class);
$deleted = $helper->cleanupStaleRoles(ExtensionOwnerType::Core, 'core', []);
$this->assertGreaterThanOrEqual(2, $deleted);
$this->assertDatabaseMissing('roles', ['identifier' => 'test-stale-with-overrides']);
$this->assertDatabaseMissing('roles', ['identifier' => 'test-stale-no-overrides']);
}
public function test_cleanupStaleRoles_blocks_deletion_when_users_reference(): void
{
$role = Role::create([
'identifier' => 'test-blocked-role',
'name' => ['ko' => '차단 역할'],
'description' => ['ko' => '차단'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
'user_overrides' => [],
]);
$user = User::factory()->create();
$user->roles()->attach($role->id);
$helper = app(ExtensionRoleSyncHelper::class);
$helper->cleanupStaleRoles(ExtensionOwnerType::Core, 'core', []);
$this->assertDatabaseHas('roles', [
'identifier' => 'test-blocked-role',
]);
}
}
@@ -0,0 +1,244 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\Helpers\GenericEntitySyncHelper;
use App\Models\NotificationDefinition;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* GenericEntitySyncHelper 회귀 방지.
*
* 단일 테이블·단일 unique 키·선택적 scope 필터 범용 패턴 검증.
*
* 실제 소비처: ShippingType, BoardType, ClaimReason, (다음 버전) Schedule.
* 본 테스트는 `NotificationDefinition` (HasUserOverrides 적용 + 스코프 컬럼 존재)
* 을 대리 대상으로 사용하여 helper 동작을 검증합니다.
*/
class GenericEntitySyncHelperTest extends TestCase
{
use RefreshDatabase;
private GenericEntitySyncHelper $helper;
protected function setUp(): void
{
parent::setUp();
$this->helper = app(GenericEntitySyncHelper::class);
}
public function test_sync_creates_new_entity(): void
{
$entity = $this->helper->sync(
NotificationDefinition::class,
['type' => 'generic-new'],
[
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '신규'],
'description' => ['ko' => '신규'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]
);
$this->assertSame('generic-new', $entity->type);
$this->assertDatabaseHas('notification_definitions', ['type' => 'generic-new']);
}
public function test_sync_preserves_user_overrides(): void
{
NotificationDefinition::create([
'type' => 'generic-override',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '사용자 수정됨'],
'description' => ['ko' => '설명'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
'user_overrides' => ['name'],
]);
$this->helper->sync(
NotificationDefinition::class,
['type' => 'generic-override'],
[
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '시더 기본값'],
'description' => ['ko' => '새 설명'],
'channels' => ['mail', 'database'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]
);
$entity = NotificationDefinition::where('type', 'generic-override')->first();
$this->assertSame(['ko' => '사용자 수정됨'], $entity->name, 'user_overrides 필드 name 은 보존');
$this->assertSame(['ko' => '새 설명'], $entity->description, '추적 외 필드는 갱신');
}
public function test_cleanupStale_with_scope_filter(): void
{
NotificationDefinition::create([
'type' => 'keep',
'hook_prefix' => 'x',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-ecommerce',
'name' => ['ko' => '유지'],
'description' => ['ko' => '유지'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationDefinition::create([
'type' => 'remove',
'hook_prefix' => 'x',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-ecommerce',
'name' => ['ko' => '삭제'],
'description' => ['ko' => '삭제'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationDefinition::create([
'type' => 'other-module',
'hook_prefix' => 'x',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => '타 모듈'],
'description' => ['ko' => '타 모듈'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStale(
NotificationDefinition::class,
['extension_type' => 'module', 'extension_identifier' => 'sirsoft-ecommerce'],
'type',
['keep'],
);
$this->assertSame(1, $deleted);
$this->assertDatabaseHas('notification_definitions', ['type' => 'keep']);
$this->assertDatabaseMissing('notification_definitions', ['type' => 'remove']);
$this->assertDatabaseHas('notification_definitions', ['type' => 'other-module'], null, 'scope 외 데이터 보호');
}
public function test_cleanupStale_deletes_even_with_user_overrides(): void
{
NotificationDefinition::create([
'type' => 'stale-overridden',
'hook_prefix' => 'x',
'extension_type' => 'module',
'extension_identifier' => 'm1',
'name' => ['ko' => '사용자 수정'],
'description' => ['ko' => '삭제 대상'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
'user_overrides' => ['name'],
]);
$deleted = $this->helper->cleanupStale(
NotificationDefinition::class,
['extension_type' => 'module', 'extension_identifier' => 'm1'],
'type',
[],
);
$this->assertSame(1, $deleted);
$this->assertDatabaseMissing('notification_definitions', ['type' => 'stale-overridden']);
}
public function test_cleanupStale_empty_scope_affects_whole_table(): void
{
NotificationDefinition::create([
'type' => 'a',
'hook_prefix' => 'x',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => 'a'],
'description' => ['ko' => 'a'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationDefinition::create([
'type' => 'b',
'hook_prefix' => 'x',
'extension_type' => 'module',
'extension_identifier' => 'm1',
'name' => ['ko' => 'b'],
'description' => ['ko' => 'b'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStale(
NotificationDefinition::class,
[],
'type',
['a'],
);
$this->assertSame(1, $deleted, '빈 scope 는 전체 테이블 대상');
$this->assertDatabaseHas('notification_definitions', ['type' => 'a']);
$this->assertDatabaseMissing('notification_definitions', ['type' => 'b']);
}
public function test_cleanupStale_noop_when_all_current(): void
{
NotificationDefinition::create([
'type' => 'single',
'hook_prefix' => 'x',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => 'single'],
'description' => ['ko' => 'single'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStale(
NotificationDefinition::class,
['extension_type' => 'core'],
'type',
['single'],
);
$this->assertSame(0, $deleted);
$this->assertDatabaseHas('notification_definitions', ['type' => 'single']);
}
}
@@ -0,0 +1,166 @@
<?php
namespace Tests\Unit\Extension\Helpers;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\Helpers\DependencyEnricher;
use App\Models\Module;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DependencyEnricherTest extends TestCase
{
#[Test]
public function enrich_빈_의존성은_빈_배열을_반환합니다(): void
{
$result = DependencyEnricher::enrich([]);
$this->assertSame([], $result);
}
#[Test]
public function enrich_modules_plugins_키가_없으면_빈_배열을_반환합니다(): void
{
$this->assertSame([], DependencyEnricher::enrich(['foo' => 'bar']));
}
#[Test]
public function enrich_미설치_모듈은_installed_version_null_과_is_met_false를_반환합니다(): void
{
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn(null);
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
]);
$this->assertCount(1, $result);
$this->assertSame('sirsoft-ecommerce', $result[0]['identifier']);
$this->assertSame('sirsoft-ecommerce', $result[0]['name']);
$this->assertSame('module', $result[0]['type']);
$this->assertSame('>=1.0.0', $result[0]['required_version']);
$this->assertNull($result[0]['installed_version']);
$this->assertFalse($result[0]['is_active']);
$this->assertFalse($result[0]['is_met']);
}
#[Test]
public function enrich_설치_활성_모듈은_is_met_true를_반환합니다(): void
{
$module = new Module(['name' => ['ko' => '상점', 'en' => 'Shop'], 'version' => '1.2.0']);
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn($module);
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
]);
$this->assertCount(1, $result);
$this->assertSame('1.2.0', $result[0]['installed_version']);
$this->assertTrue($result[0]['is_active']);
$this->assertTrue($result[0]['is_met']);
}
#[Test]
public function enrich_설치되었지만_비활성_모듈은_is_met_false를_반환합니다(): void
{
$module = new Module(['name' => ['ko' => '상점'], 'version' => '1.2.0']);
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn($module);
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
]);
$this->assertSame('1.2.0', $result[0]['installed_version']);
$this->assertFalse($result[0]['is_active']);
$this->assertFalse($result[0]['is_met']);
}
#[Test]
public function enrich_버전_미충족_모듈은_is_met_false를_반환합니다(): void
{
$module = new Module(['name' => ['ko' => '상점'], 'version' => '0.9.0']);
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn($module);
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
]);
$this->assertSame('0.9.0', $result[0]['installed_version']);
$this->assertTrue($result[0]['is_active']);
$this->assertFalse($result[0]['is_met']);
}
#[Test]
public function enrich_modules_와_plugins를_순서대로_반환합니다(): void
{
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn(null);
$moduleRepo->method('findActiveByIdentifier')->willReturn(null);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$pluginRepo->method('findByIdentifier')->willReturn(null);
$pluginRepo->method('findActiveByIdentifier')->willReturn(null);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-board' => '>=1.0.0'],
'plugins' => ['sirsoft-tosspayments' => '>=0.1.0'],
]);
$this->assertCount(2, $result);
$this->assertSame('module', $result[0]['type']);
$this->assertSame('sirsoft-board', $result[0]['identifier']);
$this->assertSame('plugin', $result[1]['type']);
$this->assertSame('sirsoft-tosspayments', $result[1]['identifier']);
}
#[Test]
public function enrich_다국어_이름_배열에서_현재_로케일을_선택합니다(): void
{
app()->setLocale('ko');
$module = new Module(['name' => ['ko' => '게시판', 'en' => 'Board'], 'version' => '1.0.0']);
$moduleRepo = $this->createMock(ModuleRepositoryInterface::class);
$moduleRepo->method('findByIdentifier')->willReturn($module);
$moduleRepo->method('findActiveByIdentifier')->willReturn($module);
$this->app->instance(ModuleRepositoryInterface::class, $moduleRepo);
$pluginRepo = $this->createMock(PluginRepositoryInterface::class);
$this->app->instance(PluginRepositoryInterface::class, $pluginRepo);
$result = DependencyEnricher::enrich([
'modules' => ['sirsoft-board' => '*'],
]);
$this->assertSame('게시판', $result[0]['name']);
}
}
+321 -52
View File
@@ -3,6 +3,10 @@
namespace Tests\Unit\Extension\Helpers;
use App\Extension\Helpers\GithubHelper;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -97,58 +101,6 @@ class GithubHelperTest extends TestCase
$this->assertSame('Authorization: Bearer ghp_test_token', $headers[2]);
}
// ──────────────────────────────────────────
// extractStatusCode
// ──────────────────────────────────────────
#[Test]
public function extractStatusCode_200_응답을_추출합니다(): void
{
$statusCode = GithubHelper::extractStatusCode(['HTTP/1.1 200 OK']);
$this->assertSame(200, $statusCode);
}
#[Test]
public function extractStatusCode_404_응답을_추출합니다(): void
{
$statusCode = GithubHelper::extractStatusCode(['HTTP/1.1 404 Not Found']);
$this->assertSame(404, $statusCode);
}
#[Test]
public function extractStatusCode_빈_헤더에서_0을_반환합니다(): void
{
$statusCode = GithubHelper::extractStatusCode([]);
$this->assertSame(0, $statusCode);
}
#[Test]
public function extractStatusCode_리다이렉트_시_마지막_상태코드를_반환합니다(): void
{
$statusCode = GithubHelper::extractStatusCode([
'HTTP/1.1 301 Moved Permanently',
'Location: https://example.com',
'HTTP/1.1 200 OK',
]);
$this->assertSame(200, $statusCode);
}
#[Test]
public function extractStatusCode_302_후_200_리다이렉트_체인을_처리합니다(): void
{
$statusCode = GithubHelper::extractStatusCode([
'HTTP/1.1 302 Found',
'HTTP/1.1 302 Found',
'HTTP/1.1 200 OK',
]);
$this->assertSame(200, $statusCode);
}
// ──────────────────────────────────────────
// parseUrl 경계 케이스
// ──────────────────────────────────────────
@@ -171,4 +123,321 @@ class GithubHelperTest extends TestCase
$this->assertSame('owner', $owner);
$this->assertSame('repo', $repo);
}
// ──────────────────────────────────────────
// checkRepoExists (Http::fake 기반)
// ──────────────────────────────────────────
#[Test]
public function checkRepoExists_200_응답이면_true를_반환합니다(): void
{
Http::fake([
'api.github.com/repos/owner/repo' => Http::response(['full_name' => 'owner/repo'], 200),
]);
$this->assertTrue(GithubHelper::checkRepoExists('owner', 'repo'));
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.github.com/repos/owner/repo'
&& $request->hasHeader('User-Agent', 'G7')
&& $request->hasHeader('Accept', 'application/vnd.github.v3+json');
});
}
#[Test]
public function checkRepoExists_404_응답이면_false를_반환합니다(): void
{
Http::fake([
'api.github.com/repos/*' => Http::response([], 404),
]);
$this->assertFalse(GithubHelper::checkRepoExists('owner', 'repo'));
}
#[Test]
public function checkRepoExists_연결_실패_시_false를_반환합니다(): void
{
Http::fake(function () {
throw new ConnectionException('Connection refused');
});
$this->assertFalse(GithubHelper::checkRepoExists('owner', 'repo'));
}
#[Test]
public function checkRepoExists_토큰이_있으면_Authorization_헤더를_포함합니다(): void
{
Http::fake([
'api.github.com/*' => Http::response([], 200),
]);
GithubHelper::checkRepoExists('owner', 'repo', 'ghp_test_token');
Http::assertSent(function (Request $request) {
return $request->hasHeader('Authorization', 'Bearer ghp_test_token');
});
}
// ──────────────────────────────────────────
// fetchLatestRelease (Http::fake 기반)
// ──────────────────────────────────────────
#[Test]
public function fetchLatestRelease_200_응답이면_버전과_zipball_url을_반환합니다(): void
{
Http::fake([
'api.github.com/repos/owner/repo/releases/latest' => Http::response([
'tag_name' => 'v7.0.1',
'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v7.0.1',
], 200),
]);
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
$this->assertSame('7.0.1', $result['version']);
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v7.0.1', $result['zipball_url']);
$this->assertNull($result['error']);
}
#[Test]
public function fetchLatestRelease_v접두사_없는_tag_name을_정상_처리합니다(): void
{
Http::fake([
'api.github.com/*' => Http::response(['tag_name' => '1.2.3', 'zipball_url' => 'https://example.com/z'], 200),
]);
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
$this->assertSame('1.2.3', $result['version']);
}
#[Test]
public function fetchLatestRelease_404_응답이면_version_null과_error_null을_반환합니다(): void
{
Http::fake([
'api.github.com/*' => Http::response([], 404),
]);
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
$this->assertNull($result['version']);
$this->assertNull($result['zipball_url']);
$this->assertNull($result['error']);
}
#[Test]
public function fetchLatestRelease_연결_실패_시_error_메시지를_반환합니다(): void
{
Http::fake(function () {
throw new ConnectionException('Network unreachable');
});
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
$this->assertNull($result['version']);
$this->assertNull($result['zipball_url']);
$this->assertNotNull($result['error']);
$this->assertStringContainsString('GitHub', $result['error']);
}
#[Test]
public function fetchLatestRelease_tag_name_누락이면_version_null을_반환합니다(): void
{
Http::fake([
'api.github.com/*' => Http::response(['name' => 'release name only'], 200),
]);
$result = GithubHelper::fetchLatestRelease('owner', 'repo');
$this->assertNull($result['version']);
}
// ──────────────────────────────────────────
// downloadArchive (sink 기반)
// ──────────────────────────────────────────
#[Test]
public function downloadArchive_성공_시_파일을_생성합니다(): void
{
$savePath = sys_get_temp_dir().'/github_test_'.uniqid().'.zip';
Http::fake([
'example.com/*' => Http::response('fake-zip-binary', 200),
]);
try {
GithubHelper::downloadArchive('https://example.com/archive.zip', $savePath);
$this->assertTrue(File::exists($savePath));
$this->assertSame('fake-zip-binary', File::get($savePath));
} finally {
if (File::exists($savePath)) {
File::delete($savePath);
}
}
}
#[Test]
public function downloadArchive_실패_응답이면_RuntimeException을_던집니다(): void
{
$savePath = sys_get_temp_dir().'/github_test_'.uniqid().'.zip';
Http::fake([
'example.com/*' => Http::response('', 404),
]);
try {
$this->expectException(\RuntimeException::class);
GithubHelper::downloadArchive('https://example.com/missing.zip', $savePath);
} finally {
if (File::exists($savePath)) {
File::delete($savePath);
}
}
}
#[Test]
public function downloadArchive_연결_실패_시_RuntimeException을_던집니다(): void
{
Http::fake(function () {
throw new ConnectionException('Timeout');
});
$this->expectException(\RuntimeException::class);
GithubHelper::downloadArchive(
'https://example.com/archive.zip',
sys_get_temp_dir().'/github_test_'.uniqid().'.zip'
);
}
// ──────────────────────────────────────────
// downloadArchiveContent (메모리 반환)
// ──────────────────────────────────────────
#[Test]
public function downloadArchiveContent_성공_시_바이너리_내용을_반환합니다(): void
{
Http::fake([
'example.com/*' => Http::response('binary-content', 200),
]);
$content = GithubHelper::downloadArchiveContent('https://example.com/archive.zip');
$this->assertSame('binary-content', $content);
}
#[Test]
public function downloadArchiveContent_실패_응답이면_RuntimeException을_던집니다(): void
{
Http::fake([
'example.com/*' => Http::response('', 500),
]);
$this->expectException(\RuntimeException::class);
GithubHelper::downloadArchiveContent('https://example.com/archive.zip');
}
// ──────────────────────────────────────────
// resolveArchiveUrl (HEAD 요청 폴백)
// ──────────────────────────────────────────
#[Test]
public function resolveArchiveUrl_v접두사_태그_200이면_해당_URL을_반환합니다(): void
{
Http::fake([
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 200),
]);
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v1.0.0', $url);
}
#[Test]
public function resolveArchiveUrl_v접두사_404이면_순수버전으로_폴백합니다(): void
{
Http::fake([
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 404),
'api.github.com/repos/owner/repo/zipball/1.0.0' => Http::response('', 200),
]);
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/1.0.0', $url);
}
#[Test]
public function resolveArchiveUrl_둘다_404이면_null을_반환합니다(): void
{
Http::fake([
'api.github.com/*' => Http::response('', 404),
]);
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
$this->assertNull($url);
}
#[Test]
public function resolveArchiveUrl_302이면_해당_URL을_반환합니다(): void
{
Http::fake([
'api.github.com/repos/owner/repo/zipball/v1.0.0' => Http::response('', 302),
]);
$url = GithubHelper::resolveArchiveUrl('owner', 'repo', '1.0.0');
$this->assertSame('https://api.github.com/repos/owner/repo/zipball/v1.0.0', $url);
}
// ──────────────────────────────────────────
// fetchRawFile (raw.githubusercontent.com)
// ──────────────────────────────────────────
#[Test]
public function fetchRawFile_원본_ref로_200이면_내용을_반환합니다(): void
{
Http::fake([
'raw.githubusercontent.com/owner/repo/main/CHANGELOG.md' => Http::response('# Changelog', 200),
]);
$content = GithubHelper::fetchRawFile('owner', 'repo', 'main', 'CHANGELOG.md');
$this->assertSame('# Changelog', $content);
}
#[Test]
public function fetchRawFile_v접두사_없는_태그는_v접두사로_폴백합니다(): void
{
Http::fake([
'raw.githubusercontent.com/owner/repo/1.0.0/README.md' => Http::response('', 404),
'raw.githubusercontent.com/owner/repo/v1.0.0/README.md' => Http::response('readme body', 200),
]);
$content = GithubHelper::fetchRawFile('owner', 'repo', '1.0.0', 'README.md');
$this->assertSame('readme body', $content);
}
#[Test]
public function fetchRawFile_모두_404이면_null을_반환합니다(): void
{
Http::fake([
'raw.githubusercontent.com/*' => Http::response('', 404),
]);
$this->assertNull(GithubHelper::fetchRawFile('owner', 'repo', 'main', 'missing.md'));
}
#[Test]
public function fetchRawFile_연결_실패_시_null을_반환합니다(): void
{
Http::fake(function () {
throw new ConnectionException('DNS failure');
});
$this->assertNull(GithubHelper::fetchRawFile('owner', 'repo', 'main', 'CHANGELOG.md'));
}
}
@@ -0,0 +1,107 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\HookArgumentSerializer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Tests\TestCase;
/**
* HookArgumentSerializer 테스트
*
* 훅 인자의 직렬화/역직렬화를 검증합니다.
*/
class HookArgumentSerializerTest extends TestCase
{
use RefreshDatabase;
/**
* 스칼라 값이 그대로 직렬화/역직렬화되는지 검증합니다.
*/
public function test_scalar_values_pass_through(): void
{
$args = ['hello', 123, 45.6, true, null];
$serialized = HookArgumentSerializer::serialize($args);
$deserialized = HookArgumentSerializer::deserialize($serialized);
$this->assertEquals($args, $deserialized);
}
/**
* 배열이 재귀적으로 직렬화/역직렬화되는지 검증합니다.
*/
public function test_nested_arrays_are_serialized_recursively(): void
{
$args = [['key' => 'value', 'nested' => ['a' => 1, 'b' => 2]]];
$serialized = HookArgumentSerializer::serialize($args);
$deserialized = HookArgumentSerializer::deserialize($serialized);
$this->assertEquals($args, $deserialized);
}
/**
* Eloquent Model이 클래스명 + PK로 직렬화되고 DB에서 복원되는지 검증합니다.
*/
public function test_eloquent_model_serialization(): void
{
$user = User::factory()->create(['name' => 'TestUser']);
$serialized = HookArgumentSerializer::serialize([$user]);
// 직렬화 결과에 마커와 클래스명, ID 포함
$this->assertTrue($serialized[0]['__hook_model__']);
$this->assertEquals(User::class, $serialized[0]['class']);
$this->assertEquals($user->id, $serialized[0]['id']);
// 역직렬화 시 DB에서 조회하여 복원
$deserialized = HookArgumentSerializer::deserialize($serialized);
$this->assertInstanceOf(User::class, $deserialized[0]);
$this->assertEquals($user->id, $deserialized[0]->id);
}
/**
* Collection이 직렬화/역직렬화되는지 검증합니다.
*/
public function test_collection_serialization(): void
{
$collection = collect(['a', 'b', 'c']);
$serialized = HookArgumentSerializer::serialize([$collection]);
$this->assertTrue($serialized[0]['__hook_collection__']);
$deserialized = HookArgumentSerializer::deserialize($serialized);
$this->assertInstanceOf(Collection::class, $deserialized[0]);
$this->assertEquals(['a', 'b', 'c'], $deserialized[0]->all());
}
/**
* Closure 등 직렬화 불가 객체가 null로 대체되는지 검증합니다.
*/
public function test_non_serializable_objects_become_null(): void
{
$closure = function () { return 'test'; };
$serialized = HookArgumentSerializer::serialize([$closure]);
$this->assertNull($serialized[0]);
}
/**
* 혼합 인자 배열이 올바르게 처리되는지 검증합니다.
*/
public function test_mixed_arguments(): void
{
$user = User::factory()->create();
$args = ['action_name', $user, ['extra' => 'data'], 42];
$serialized = HookArgumentSerializer::serialize($args);
$deserialized = HookArgumentSerializer::deserialize($serialized);
$this->assertEquals('action_name', $deserialized[0]);
$this->assertInstanceOf(User::class, $deserialized[1]);
$this->assertEquals($user->id, $deserialized[1]->id);
$this->assertEquals(['extra' => 'data'], $deserialized[2]);
$this->assertEquals(42, $deserialized[3]);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\HookContextCapture;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Tests\TestCase;
/**
* HookContextCapture 테스트
*
* 큐 워커에서 Auth/Request/Locale 컨텍스트를 캡처하고 복원하는 동작을 검증합니다.
*/
class HookContextCaptureTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
HookManager::resetAll();
}
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
/**
* capture()가 현재 Auth/Request/Locale을 스냅샷하는지 검증합니다.
*/
public function test_capture_returns_current_request_context(): void
{
$user = User::factory()->create();
Auth::login($user);
App::setLocale('ko');
$request = Request::create('/api/admin/users', 'GET', [], [], [], [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_USER_AGENT' => 'TestAgent/1.0',
]);
app()->instance('request', $request);
$context = HookContextCapture::capture();
$this->assertEquals($user->id, $context['user_id']);
$this->assertEquals('203.0.113.10', $context['ip_address']);
$this->assertEquals('TestAgent/1.0', $context['user_agent']);
$this->assertEquals('ko', $context['locale']);
$this->assertEquals('api/admin/users', $context['path']);
}
/**
* 미인증 상태에서도 capture()가 안전하게 동작하는지 검증합니다.
*/
public function test_capture_handles_unauthenticated_state(): void
{
Auth::logout();
$context = HookContextCapture::capture();
$this->assertNull($context['user_id']);
$this->assertArrayHasKey('locale', $context);
}
/**
* restore()가 Auth, Locale, Request를 복원하는지 검증합니다.
*/
public function test_restore_recovers_auth_locale_and_request(): void
{
$user = User::factory()->create();
// 큐 워커처럼 빈 컨텍스트에서 시작
Auth::logout();
App::setLocale('en');
HookContextCapture::restore([
'user_id' => $user->id,
'ip_address' => '198.51.100.7',
'user_agent' => 'WorkerAgent/2.0',
'locale' => 'ko',
'path' => 'api/admin/settings',
]);
$this->assertEquals($user->id, Auth::id());
$this->assertEquals('ko', App::getLocale());
$this->assertEquals('198.51.100.7', request()->ip());
$this->assertEquals('WorkerAgent/2.0', request()->userAgent());
$this->assertEquals('api/admin/settings', request()->path());
$this->assertTrue(request()->is('api/admin/*'));
}
/**
* 빈 컨텍스트(user_id null 등)에서도 restore()가 예외 없이 동작합니다.
*/
public function test_restore_handles_empty_context(): void
{
Auth::logout();
HookContextCapture::restore([
'user_id' => null,
'ip_address' => null,
'user_agent' => null,
'locale' => null,
'path' => null,
]);
$this->assertNull(Auth::id());
}
/**
* capture() 시 hook.context.capture 필터로 플러그인이 키를 추가할 수 있는지 검증합니다.
*/
public function test_capture_filter_allows_extension_to_add_keys(): void
{
HookManager::addFilter('hook.context.capture', function (array $context) {
$context['tenant_id'] = 'tenant-42';
return $context;
});
$context = HookContextCapture::capture();
$this->assertEquals('tenant-42', $context['tenant_id']);
}
/**
* restore() 시 hook.context.restore 액션으로 플러그인 복원 로직이 실행되는지 검증합니다.
*/
public function test_restore_action_fires_for_extension_recovery(): void
{
$received = null;
HookManager::addAction('hook.context.restore', function (array $context) use (&$received) {
$received = $context['tenant_id'] ?? null;
});
HookContextCapture::restore([
'user_id' => null,
'tenant_id' => 'tenant-99',
]);
$this->assertEquals('tenant-99', $received);
}
}
@@ -0,0 +1,222 @@
<?php
namespace Tests\Unit\Extension;
use App\Contracts\Extension\HookListenerInterface;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
use App\Jobs\DispatchHookListenerJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
/**
* HookListenerRegistrar 테스트
*
* 리스너 등록 시 큐/동기 분기와 filter 등록을 검증합니다.
*/
class HookListenerRegistrarTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
HookManager::resetAll();
}
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
/**
* 기본 Action 리스너가 큐 Job으로 디스패치되는지 검증합니다.
*/
public function test_action_listener_dispatches_job_by_default(): void
{
Queue::fake();
HookListenerRegistrar::register(StubActionListener::class);
HookManager::doAction('test.registrar.action', 'hello');
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) {
return $job->listenerClass === StubActionListener::class
&& $job->method === 'handleAction';
});
}
/**
* 디스패치된 Job이 현재 요청 컨텍스트(Auth/Locale/IP)를 함께 캡처하는지 검증합니다.
*
* 큐 워커는 별도 프로세스라 Auth가 사라지므로, 캡처 시점에 스냅샷을 함께
* Job 페이로드로 전달해야 워커에서 복원 가능합니다.
*/
public function test_dispatched_job_captures_request_context(): void
{
Queue::fake();
$user = \App\Models\User::factory()->create();
\Illuminate\Support\Facades\Auth::login($user);
\Illuminate\Support\Facades\App::setLocale('ko');
$request = \Illuminate\Http\Request::create('/api/admin/users', 'GET', [], [], [], [
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_USER_AGENT' => 'TestAgent/1.0',
]);
app()->instance('request', $request);
HookListenerRegistrar::register(StubActionListener::class);
HookManager::doAction('test.registrar.action', 'hello');
Queue::assertPushed(DispatchHookListenerJob::class, function ($job) use ($user) {
return $job->context['user_id'] === $user->id
&& $job->context['locale'] === 'ko'
&& $job->context['ip_address'] === '203.0.113.10'
&& $job->context['user_agent'] === 'TestAgent/1.0'
&& $job->context['path'] === 'api/admin/users';
});
}
/**
* sync: true 리스너가 동기로 실행되는지 검증합니다.
*/
public function test_sync_listener_executes_immediately(): void
{
Queue::fake();
HookListenerRegistrar::register(StubSyncListener::class);
HookManager::doAction('test.registrar.sync');
// Job이 디스패치되지 않아야 함
Queue::assertNotPushed(DispatchHookListenerJob::class);
// 동기 실행 확인
$this->assertTrue(StubSyncListener::$executed);
}
/**
* Filter 리스너가 동기로 실행되고 반환값이 정상 전달되는지 검증합니다.
*/
public function test_filter_listener_executes_synchronously(): void
{
Queue::fake();
HookListenerRegistrar::register(StubFilterListener::class);
$result = HookManager::applyFilters('test.registrar.filter', 'original');
$this->assertEquals('original_filtered', $result);
Queue::assertNotPushed(DispatchHookListenerJob::class);
}
/**
* 우선순위가 HookManager에 정확히 전달되는지 검증합니다.
*/
public function test_priority_is_passed_to_hook_manager(): void
{
Queue::fake();
$order = [];
// 우선순위 5, 20인 두 리스너를 등록하여 순서 확인
// StubPriorityListener는 sync: true로 실행 순서를 즉시 확인 가능
HookListenerRegistrar::register(StubPriorityHighListener::class);
HookListenerRegistrar::register(StubPriorityLowListener::class);
StubPriorityHighListener::$order = &$order;
StubPriorityLowListener::$order = &$order;
HookManager::doAction('test.registrar.priority');
$this->assertEquals(['high', 'low'], $order);
}
}
// --- 테스트용 스텁 클래스 ---
class StubActionListener implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'test.registrar.action' => ['method' => 'handleAction', 'priority' => 10],
];
}
public function handle(...$args): void {}
public function handleAction(string $value): void {}
}
class StubSyncListener implements HookListenerInterface
{
public static bool $executed = false;
public static function getSubscribedHooks(): array
{
return [
'test.registrar.sync' => ['method' => 'handleSync', 'priority' => 10, 'sync' => true],
];
}
public function handle(...$args): void {}
public function handleSync(): void
{
self::$executed = true;
}
}
class StubFilterListener implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'test.registrar.filter' => ['method' => 'handleFilter', 'priority' => 10, 'type' => 'filter'],
];
}
public function handle(...$args): void {}
public function handleFilter(string $value): string
{
return $value.'_filtered';
}
}
class StubPriorityHighListener implements HookListenerInterface
{
public static array $order = [];
public static function getSubscribedHooks(): array
{
return [
'test.registrar.priority' => ['method' => 'handlePriority', 'priority' => 5, 'sync' => true],
];
}
public function handle(...$args): void {}
public function handlePriority(): void
{
self::$order[] = 'high';
}
}
class StubPriorityLowListener implements HookListenerInterface
{
public static array $order = [];
public static function getSubscribedHooks(): array
{
return [
'test.registrar.priority' => ['method' => 'handlePriority', 'priority' => 20, 'sync' => true],
];
}
public function handle(...$args): void {}
public function handlePriority(): void
{
self::$order[] = 'low';
}
}
@@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Extension;
use App\Events\GenericBroadcastEvent;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
/**
* HookManager::broadcast() 테스트
*
* HookManager를 통한 WebSocket 브로드캐스트가 올바르게 동작하는지 검증합니다.
*/
class HookManagerBroadcastTest extends TestCase
{
/**
* broadcast() 호출 시 GenericBroadcastEvent가 dispatch되는지 테스트합니다.
*/
public function test_broadcast_dispatches_generic_broadcast_event(): void
{
config(['broadcasting.default' => 'reverb']);
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
Event::fake([GenericBroadcastEvent::class]);
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
'type' => 'stats',
'data' => ['users' => 100],
]);
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
return $event->channel === 'core.admin.dashboard'
&& $event->eventName === 'dashboard.stats.updated'
&& $event->payload === ['type' => 'stats', 'data' => ['users' => 100]];
});
}
/**
* broadcast()에 빈 payload가 허용되는지 테스트합니다.
*/
public function test_broadcast_allows_empty_payload(): void
{
config(['broadcasting.default' => 'reverb']);
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
Event::fake([GenericBroadcastEvent::class]);
HookManager::broadcast('core.user.notifications.123', 'notification.received');
Event::assertDispatched(GenericBroadcastEvent::class, function ($event) {
return $event->channel === 'core.user.notifications.123'
&& $event->eventName === 'notification.received'
&& $event->payload === [];
});
}
/**
* 드라이버가 null이면 브로드캐스트를 건너뛰는지 테스트합니다.
*/
public function test_broadcast_skips_when_driver_is_null(): void
{
config(['broadcasting.default' => 'null']);
Event::fake([GenericBroadcastEvent::class]);
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
Event::assertNotDispatched(GenericBroadcastEvent::class);
}
/**
* 드라이버가 log이면 브로드캐스트를 건너뛰는지 테스트합니다.
*/
public function test_broadcast_skips_when_driver_is_log(): void
{
config(['broadcasting.default' => 'log']);
Event::fake([GenericBroadcastEvent::class]);
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
Event::assertNotDispatched(GenericBroadcastEvent::class);
}
/**
* 호스트가 미설정이면 브로드캐스트를 건너뛰는지 테스트합니다.
*/
public function test_broadcast_skips_when_host_is_empty(): void
{
config(['broadcasting.default' => 'reverb']);
config(['broadcasting.connections.reverb.options.host' => null]);
Event::fake([GenericBroadcastEvent::class]);
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', ['type' => 'stats']);
Event::assertNotDispatched(GenericBroadcastEvent::class);
}
/**
* broadcast() 실패 시 예외가 전파되지 않고 로그만 기록되는지 테스트합니다.
*
* 테스트 환경에서는 reverb 자격증명이 비어 있어 Laravel BroadcastManager가
* Pusher 인스턴스 생성 단계에서 예외를 던지며, HookManager의 catch 블록이
* 이를 잡아 Log::warning으로 기록하는 동작을 검증합니다.
*/
public function test_broadcast_failure_does_not_propagate_exception(): void
{
config(['broadcasting.default' => 'reverb']);
config(['broadcasting.connections.reverb.options.host' => 'localhost']);
// 자격증명은 비워 두어 Pusher 생성 시 예외가 발생하도록 함
config(['broadcasting.connections.reverb.key' => null]);
config(['broadcasting.connections.reverb.secret' => null]);
config(['broadcasting.connections.reverb.app_id' => null]);
Log::shouldReceive('warning')
->once()
->withArgs(function (string $message, array $context) {
return $message === '브로드캐스트 실패 (Reverb 미실행 가능)'
&& $context['channel'] === 'core.admin.dashboard'
&& $context['event'] === 'dashboard.stats.updated'
&& ! empty($context['error']);
});
// 예외가 전파되지 않아야 함 — 전파되면 아래 assertion 없이 테스트가 예외로 종료됨
HookManager::broadcast('core.admin.dashboard', 'dashboard.stats.updated', [
'type' => 'stats',
]);
$this->assertTrue(true, '예외가 호출자로 전파되지 않음');
}
}
@@ -0,0 +1,96 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* `config('app.installer_completed')` 가드가 Schema::hasTable() 호출을
* 건너뛰는지 검증합니다.
*
* 성능 회귀 방지 목적: 설치 완료 상태에서 매 요청 `information_schema.tables`
* 쿼리가 실행되지 않아야 합니다.
*/
class InstallerCompletedGuardTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
config(['app.installer_completed' => false]);
parent::tearDown();
}
public function test_module_trait_skips_has_table_when_installer_completed(): void
{
config(['app.installer_completed' => true]);
DB::enableQueryLog();
ModuleManager::getActiveModuleIdentifiers();
$queries = DB::getQueryLog();
DB::disableQueryLog();
$schemaQueries = array_filter(
$queries,
fn ($q) => str_contains($q['query'], 'information_schema.tables')
);
$this->assertCount(0, $schemaQueries, 'installer_completed=true 에서는 information_schema.tables 쿼리가 발생하지 않아야 합니다');
}
public function test_plugin_trait_skips_has_table_when_installer_completed(): void
{
config(['app.installer_completed' => true]);
DB::enableQueryLog();
PluginManager::getActivePluginIdentifiers();
$queries = DB::getQueryLog();
DB::disableQueryLog();
$schemaQueries = array_filter(
$queries,
fn ($q) => str_contains($q['query'], 'information_schema.tables')
);
$this->assertCount(0, $schemaQueries);
}
public function test_template_trait_skips_has_table_when_installer_completed(): void
{
config(['app.installer_completed' => true]);
DB::enableQueryLog();
TemplateManager::getActiveTemplateIdentifiers();
$queries = DB::getQueryLog();
DB::disableQueryLog();
$schemaQueries = array_filter(
$queries,
fn ($q) => str_contains($q['query'], 'information_schema.tables')
);
$this->assertCount(0, $schemaQueries);
}
public function test_module_trait_falls_back_to_has_table_when_installer_not_completed(): void
{
config(['app.installer_completed' => false]);
DB::enableQueryLog();
ModuleManager::getActiveModuleIdentifiers();
$queries = DB::getQueryLog();
DB::disableQueryLog();
$schemaQueries = array_filter(
$queries,
fn ($q) => str_contains($q['query'], 'information_schema.tables')
);
$this->assertGreaterThan(0, count($schemaQueries), 'installer_completed=false 에서는 기존 hasTable 경로가 실행되어야 합니다');
}
}
@@ -0,0 +1,310 @@
<?php
namespace Tests\Unit\Extension;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use ReflectionMethod;
use Tests\TestCase;
/**
* ModuleManager::cleanupStaleModuleEntries() 회귀 테스트.
*
* 회귀 방지 대상: 모듈 정의의 권한은 `action` 필드로 식별되고, DB 저장 시
* `{moduleIdentifier}.{category.identifier}.{action}` 포맷으로 기록된다.
* cleanupStaleModuleEntries 가 동일 포맷으로 expected 목록을 만들지 않으면
* 모든 모듈 권한이 "stale" 로 오판되어 전수 삭제되는 치명적 회귀가 발생한다.
*
* (참고) beta.2 번들 일괄 업데이트 시 sirsoft-board / sirsoft-ecommerce 등
* 모듈 권한이 대부분 삭제된 실제 프로덕션 증상을 재현·차단한다.
*/
class ModuleCleanupStaleEntriesTest extends TestCase
{
use RefreshDatabase;
private ModuleManager $moduleManager;
private bool $ecommerceExistedBefore = false;
protected function setUp(): void
{
parent::setUp();
$modulesPath = base_path('modules');
$activePath = $modulesPath.'/sirsoft-ecommerce';
if (File::isDirectory($activePath) && ! File::exists($activePath.'/module.php')) {
File::deleteDirectory($activePath);
}
$this->ecommerceExistedBefore = File::isDirectory($activePath);
$this->moduleManager = app(ModuleManager::class);
$this->moduleManager->loadModules();
Role::create([
'identifier' => 'admin',
'name' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'description' => ['ko' => '관리자', 'en' => 'Administrator'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
Role::create([
'identifier' => 'manager',
'name' => ['ko' => '매니저', 'en' => 'Manager'],
'description' => ['ko' => '매니저', 'en' => 'Manager'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]);
User::factory()->create(['email' => 'test@test.com']);
}
protected function tearDown(): void
{
if (! $this->ecommerceExistedBefore) {
$activePath = base_path('modules/sirsoft-ecommerce');
if (File::isDirectory($activePath)) {
File::deleteDirectory($activePath);
}
}
parent::tearDown();
}
public function test_cleanup_stale_module_entries_does_not_delete_live_permissions(): void
{
$this->moduleManager->installModule('sirsoft-ecommerce');
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
$this->assertNotNull($module);
// 설치 후 ecommerce 모듈 권한 수 측정
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->count();
$this->assertGreaterThan(
10,
$countBefore,
'ecommerce 모듈은 install 직후 다수의 권한을 보유해야 합니다 (카테고리 + action 조합).'
);
// cleanupStaleModuleEntries 를 직접 실행 — 정의와 저장이 일치하는 상태라면 아무것도 삭제되면 안 됨
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
$method->invoke($this->moduleManager, $module);
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->count();
$this->assertSame(
$countBefore,
$countAfter,
'cleanupStaleModuleEntries 는 정의된 권한을 삭제하지 않아야 합니다. '
."before={$countBefore}, after={$countAfter} (손실={$this->lossCount($countBefore, $countAfter)})"
);
}
public function test_cleanup_stale_module_entries_removes_only_genuinely_stale_permissions(): void
{
$this->moduleManager->installModule('sirsoft-ecommerce');
// 인위적으로 stale 권한 1건 삽입 (정의에 없는 identifier)
Permission::create([
'identifier' => 'sirsoft-ecommerce.removed-category.obsolete-action',
'name' => ['ko' => '제거된 권한', 'en' => 'Removed permission'],
'description' => ['ko' => '제거된 권한', 'en' => 'Removed permission'],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-ecommerce',
'type' => \App\Enums\PermissionType::Admin,
'order' => 999,
'parent_id' => null,
]);
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
$liveCountBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->where('identifier', '!=', 'sirsoft-ecommerce.removed-category.obsolete-action')
->count();
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
$method->invoke($this->moduleManager, $module);
// stale 1건만 삭제되고 나머지는 보존되어야 함
$this->assertNull(
Permission::where('identifier', 'sirsoft-ecommerce.removed-category.obsolete-action')->first(),
'stale 권한은 삭제되어야 합니다.'
);
$liveCountAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->count();
$this->assertSame(
$liveCountBefore,
$liveCountAfter,
'정의된 권한은 전부 보존되어야 합니다. '
."before={$liveCountBefore}, after={$liveCountAfter}"
);
}
/**
* 동적 권한(getDynamicPermissionIdentifiers override) 이 stale cleanup 대상에서 보존되는지 검증.
*
* 회귀 시나리오: sirsoft-board 처럼 런타임에 Permission::updateOrCreate 로 추가된 권한이
* 모듈 업데이트 시점의 stale cleanup 에서 "정적 정의에 없다"는 이유로 전수 삭제되는 버그.
*/
public function test_cleanup_preserves_dynamic_permissions(): void
{
$this->moduleManager->installModule('sirsoft-ecommerce');
$module = $this->moduleManager->getModule('sirsoft-ecommerce');
// 런타임 동적 권한 모사: 정적 정의에 없는 식별자를 삽입
Permission::create([
'identifier' => 'sirsoft-ecommerce.dynamic-scope.action-alpha',
'name' => ['ko' => '동적', 'en' => 'Dynamic'],
'description' => ['ko' => '동적', 'en' => 'Dynamic'],
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => 'sirsoft-ecommerce',
'type' => \App\Enums\PermissionType::Admin,
'order' => 900,
'parent_id' => null,
]);
// Mockery partial mock: 실제 모듈 동작 유지 + getDynamicPermissionIdentifiers 만 override
$wrapper = \Mockery::mock($module)->makePartial();
$wrapper->shouldReceive('getDynamicPermissionIdentifiers')
->andReturn(['sirsoft-ecommerce.dynamic-scope.action-alpha']);
$method = new ReflectionMethod($this->moduleManager, 'cleanupStaleModuleEntries');
$method->invoke($this->moduleManager, $wrapper);
$this->assertNotNull(
Permission::where('identifier', 'sirsoft-ecommerce.dynamic-scope.action-alpha')->first(),
'동적 권한은 getDynamicPermissionIdentifiers 반환 시 보존되어야 합니다.'
);
}
/**
* uninstall(deleteData=false) 시 권한·메뉴·역할이 보존되어 재설치 경로를 비파괴적으로 만드는지 검증.
* PO 정책: "동적 권한/메뉴는 데이터 삭제 옵션 체크 시에만 삭제".
*/
public function test_uninstall_without_delete_data_preserves_permissions(): void
{
$this->moduleManager->installModule('sirsoft-ecommerce');
$countBefore = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->count();
$this->assertGreaterThan(0, $countBefore);
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: false);
$countAfter = Permission::where('extension_type', ExtensionOwnerType::Module)
->where('extension_identifier', 'sirsoft-ecommerce')
->count();
$this->assertSame(
$countBefore,
$countAfter,
'deleteData=false 시 권한은 보존되어야 합니다.'
);
}
/**
* uninstall(deleteData=true) 시 권한이 삭제되는지 (기존 동작 유지) 검증.
*/
public function test_uninstall_with_delete_data_removes_permissions(): void
{
$this->moduleManager->installModule('sirsoft-ecommerce');
$this->assertGreaterThan(
0,
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
);
$this->moduleManager->uninstallModule('sirsoft-ecommerce', deleteData: true);
$this->assertSame(
0,
Permission::where('extension_identifier', 'sirsoft-ecommerce')->count(),
'deleteData=true 시 권한은 전수 삭제되어야 합니다.'
);
}
/**
* updateComposerAutoload 가 cleanupStaleModuleEntries 보다 먼저 호출되는 순서를 검증.
*
* 회귀 시나리오: 동적 hook(getDynamicPermissionIdentifiers 등)이 모듈 클래스(예: Board 모델)를
* 참조할 때, 현재 프로세스의 PSR-4 매핑이 갱신되기 전에 cleanup 이 실행되면 "Class not found"
* 가 발생한다. 본 테스트는 ModuleManager::updateModule 의 소스 구조적 순서를 검증한다.
*/
public function test_update_module_calls_autoload_before_cleanup(): void
{
$source = file_get_contents(base_path('app/Extension/ModuleManager.php'));
$start = strpos($source, 'public function updateModule(');
$this->assertNotFalse($start, 'updateModule 메서드를 찾을 수 없습니다.');
// 다음 메서드(visibility 무관) 시작 지점을 본문 종료로 사용
$end = false;
foreach (['public function ', 'protected function ', 'private function '] as $kw) {
$pos = strpos($source, $kw, $start + 1);
if ($pos !== false && ($end === false || $pos < $end)) {
$end = $pos;
}
}
$this->assertNotFalse($end, '다음 메서드를 찾지 못해 본문 범위 결정 실패');
$body = substr($source, $start, $end - $start);
$autoloadPos = strpos($body, '$this->extensionManager->updateComposerAutoload();');
$cleanupPos = strpos($body, '$this->cleanupStaleModuleEntries(');
$this->assertNotFalse($autoloadPos, 'updateComposerAutoload 호출을 찾을 수 없습니다.');
$this->assertNotFalse($cleanupPos, 'cleanupStaleModuleEntries 호출을 찾을 수 없습니다.');
$this->assertLessThan(
$cleanupPos,
$autoloadPos,
'updateComposerAutoload 는 cleanupStaleModuleEntries 보다 먼저 호출되어야 한다. '
.'(동적 hook 의 모듈 클래스 autoload 보장)'
);
}
public function test_update_plugin_calls_autoload_before_cleanup(): void
{
$source = file_get_contents(base_path('app/Extension/PluginManager.php'));
$start = strpos($source, 'public function updatePlugin(');
$this->assertNotFalse($start, 'updatePlugin 메서드를 찾을 수 없습니다.');
// 다음 메서드(visibility 무관) 시작 지점을 본문 종료로 사용
$end = false;
foreach (['public function ', 'protected function ', 'private function '] as $kw) {
$pos = strpos($source, $kw, $start + 1);
if ($pos !== false && ($end === false || $pos < $end)) {
$end = $pos;
}
}
$this->assertNotFalse($end, '다음 메서드를 찾지 못해 본문 범위 결정 실패');
$body = substr($source, $start, $end - $start);
$autoloadPos = strpos($body, '$this->extensionManager->updateComposerAutoload();');
$cleanupPos = strpos($body, '$this->cleanupStalePluginEntries(');
$this->assertNotFalse($autoloadPos);
$this->assertNotFalse($cleanupPos);
$this->assertLessThan(
$cleanupPos,
$autoloadPos,
'updateComposerAutoload 는 cleanupStalePluginEntries 보다 먼저 호출되어야 한다.'
);
}
private function lossCount(int $before, int $after): int
{
return max(0, $before - $after);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\AbstractModule;
use App\Extension\ModuleManager;
use Mockery;
use ReflectionClass;
use Tests\TestCase;
/**
* resolveForceUpdateSource() 회귀 테스트.
*
* PO 정책 (2026-04-14): --force 시 번들 우선, 번들 없으면 GitHub 폴백.
*
* 본 테스트는 ModuleManager 의 private 메서드를 Reflection으로 직접 검증한다.
* AbstractModule 은 getIdentifier/getVendor 가 final 이므로 Mockery 로 모킹한다.
*/
class ModuleForceUpdateSourceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function invokeResolve(ModuleManager $manager, string $identifier): ?string
{
$reflection = new ReflectionClass($manager);
$method = $reflection->getMethod('resolveForceUpdateSource');
$method->setAccessible(true);
return $method->invoke($manager, $identifier);
}
private function setBundledModules(ModuleManager $manager, array $bundled): void
{
$reflection = new ReflectionClass($manager);
$prop = $reflection->getProperty('bundledModules');
$prop->setAccessible(true);
$prop->setValue($manager, $bundled);
}
private function setLoadedModules(ModuleManager $manager, array $modules): void
{
$reflection = new ReflectionClass($manager);
$prop = $reflection->getProperty('modules');
$prop->setAccessible(true);
$prop->setValue($manager, $modules);
}
private function makeFakeModule(?string $githubUrl): AbstractModule
{
$mock = Mockery::mock(AbstractModule::class);
$mock->shouldReceive('getGithubUrl')->andReturn($githubUrl);
return $mock;
}
public function test_resolves_bundled_when_in_memory_cache(): void
{
$manager = app(ModuleManager::class);
$this->setBundledModules($manager, [
'test-mod' => ['version' => '1.0.0'],
]);
$this->assertSame('bundled', $this->invokeResolve($manager, 'test-mod'));
}
public function test_resolves_github_when_no_bundle_but_github_url_present(): void
{
$manager = app(ModuleManager::class);
$this->setBundledModules($manager, []);
$this->setLoadedModules($manager, [
'test-mod' => $this->makeFakeModule('https://github.com/test/repo'),
]);
$this->assertSame('github', $this->invokeResolve($manager, 'test-mod'));
}
public function test_returns_null_when_neither_bundle_nor_github_available(): void
{
$manager = app(ModuleManager::class);
$this->setBundledModules($manager, []);
$this->setLoadedModules($manager, [
'test-mod' => $this->makeFakeModule(null),
]);
$this->assertNull($this->invokeResolve($manager, 'test-mod'));
}
public function test_prefers_bundled_over_github_when_both_exist(): void
{
$manager = app(ModuleManager::class);
// 번들과 GitHub 모두 존재하는 시나리오 — PO 정책상 bundled 우선
$this->setBundledModules($manager, [
'test-mod' => ['version' => '1.0.0'],
]);
$this->setLoadedModules($manager, [
'test-mod' => $this->makeFakeModule('https://github.com/test/repo'),
]);
$this->assertSame('bundled', $this->invokeResolve($manager, 'test-mod'));
}
}
@@ -45,7 +45,7 @@ class ModuleManagerDependencyTest extends TestCase
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getDependencies')
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
@@ -68,7 +68,7 @@ class ModuleManagerDependencyTest extends TestCase
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getDependencies')
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
@@ -92,7 +92,7 @@ class ModuleManagerDependencyTest extends TestCase
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getDependencies')
->andReturn(['sirsoft-payment' => '>=1.0.0']);
->andReturn(['plugins' => ['sirsoft-payment' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
@@ -106,7 +106,7 @@ class ModuleManagerDependencyTest extends TestCase
{
$module = \Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getDependencies')
->andReturn(['nonexistent-extension' => '>=1.0.0']);
->andReturn(['modules' => ['nonexistent-extension' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->moduleManager, 'checkDependencies');
@@ -115,7 +115,11 @@ class ModuleManagerDependencyTest extends TestCase
}
/**
* enrichDependencies가 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
* enrichDependencies가 중첩 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
*
* manifest(module.json/plugin.json)의 dependencies 필드는 중첩 구조
* ['modules' => [...], 'plugins' => [...]] 로 저장되므로 enrichDependencies는
* 해당 구조를 그대로 파싱합니다 (커밋 72156e6f0, 2026-02-23).
*/
public function test_enrich_dependencies_returns_module_identifier_not_version(): void
{
@@ -130,7 +134,9 @@ class ModuleManagerDependencyTest extends TestCase
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
$dependencies = [
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
];
$result = $reflection->invoke($this->moduleManager, $dependencies);
$this->assertCount(1, $result);
@@ -140,7 +146,7 @@ class ModuleManagerDependencyTest extends TestCase
}
/**
* enrichDependencies가 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
* enrichDependencies가 중첩 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
*/
public function test_enrich_dependencies_returns_plugin_identifier(): void
{
@@ -155,7 +161,9 @@ class ModuleManagerDependencyTest extends TestCase
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
$dependencies = [
'plugins' => ['sirsoft-payment' => '>=1.0.0'],
];
$result = $reflection->invoke($this->moduleManager, $dependencies);
$this->assertCount(1, $result);
@@ -164,17 +172,38 @@ class ModuleManagerDependencyTest extends TestCase
}
/**
* enrichDependencies가 미등록 의존성을 unknown 타입으로 반환하는지 테스트합니다.
* enrichDependencies가 미등록 의존성의 identifier를 그대로 반환하는지 테스트합니다.
*
* DB에 없는 의존성은 name 필드에 identifier를 사용하지만 type은 여전히
* 선언된 구조(module/plugin)를 따릅니다.
*/
public function test_enrich_dependencies_returns_unknown_for_missing(): void
public function test_enrich_dependencies_returns_identifier_for_missing(): void
{
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
$dependencies = ['nonexistent-extension' => '>=1.0.0'];
$dependencies = [
'modules' => ['nonexistent-extension' => '>=1.0.0'],
];
$result = $reflection->invoke($this->moduleManager, $dependencies);
$this->assertCount(1, $result);
$this->assertEquals('nonexistent-extension', $result[0]['identifier']);
$this->assertEquals('unknown', $result[0]['type']);
$this->assertEquals('nonexistent-extension', $result[0]['name']);
$this->assertEquals('module', $result[0]['type']);
}
/**
* enrichDependencies가 중첩 구조가 아닌 경우 빈 배열을 반환하는지 테스트합니다.
*
* manifest 형식이 아닌 평면 배열 등은 파싱되지 않아야 합니다.
*/
public function test_enrich_dependencies_returns_empty_for_non_nested_structure(): void
{
$reflection = new \ReflectionMethod($this->moduleManager, 'enrichDependencies');
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
$result = $reflection->invoke($this->moduleManager, $dependencies);
$this->assertCount(0, $result);
}
}
@@ -181,7 +181,7 @@ class ModuleManagerLayoutTest extends TestCase
]);
// LayoutService와 동일한 캐시 키 패턴으로 설정: template.{templateId}.layout.{layoutName}
$cacheKey = "template.{$this->adminTemplate->id}.layout.{$layoutName}";
$cacheKey = "g7:core:template.{$this->adminTemplate->id}.layout.{$layoutName}";
Cache::put($cacheKey, $layout->content, 3600);
$this->assertNotNull(Cache::get($cacheKey));
@@ -519,13 +519,128 @@ class ModuleManagerLayoutTest extends TestCase
$this->assertEquals('test-prefix-module.admin_sample_index', $layout->name);
}
/**
* registerLayoutToTemplate 호출 시 original_content_hash / size 가 저장되는지 테스트.
*
* layout_strategy=keep 에서 사용자 수정 감지에 사용되므로 신규 등록 시 hash 가 채워져야 함.
*/
public function test_register_layout_stores_original_content_hash_and_size(): void
{
$moduleIdentifier = 'test-hash-module';
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'test',
'name' => ['ko' => '해시 테스트', 'en' => 'Hash Test'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$layoutData = [
'layout_name' => 'admin_home',
'version' => '1.0.0',
'slots' => ['content' => [['component' => 'Div']]],
];
$reflection = new \ReflectionClass($this->moduleManager);
$method = $reflection->getMethod('registerLayoutToTemplate');
$method->setAccessible(true);
$method->invoke(
$this->moduleManager,
$this->adminTemplate,
$moduleIdentifier.'.admin_home',
$layoutData,
$moduleIdentifier,
);
$layout = TemplateLayout::where('template_id', $this->adminTemplate->id)
->where('source_identifier', $moduleIdentifier)
->first();
$this->assertNotNull($layout);
$this->assertNotNull($layout->original_content_hash, 'hash 가 저장되어야 함');
$this->assertEquals(64, strlen($layout->original_content_hash), 'SHA-256 hex length');
$this->assertGreaterThan(0, $layout->original_content_size);
// hash 재계산이 동일한지
$expectedHash = hash('sha256', json_encode($layoutData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->assertEquals($expectedHash, $layout->original_content_hash);
}
/**
* hasModifiedLayouts: 수정된 레이아웃이 없을 때 결과 검증.
*/
public function test_has_modified_layouts_returns_false_when_unchanged(): void
{
$moduleIdentifier = 'test-nomod-module';
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'test',
'name' => ['ko' => '미수정 테스트', 'en' => 'Unmodified Test'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$layoutData = ['layout_name' => 'home', 'slots' => []];
$reflection = new \ReflectionClass($this->moduleManager);
$method = $reflection->getMethod('registerLayoutToTemplate');
$method->setAccessible(true);
$method->invoke($this->moduleManager, $this->adminTemplate, $moduleIdentifier.'.home', $layoutData, $moduleIdentifier);
$result = $this->moduleManager->hasModifiedLayouts($moduleIdentifier);
$this->assertFalse($result['has_modified_layouts']);
$this->assertEquals(0, $result['modified_count']);
$this->assertEmpty($result['modified_layouts']);
}
/**
* hasModifiedLayouts: 사용자가 content 를 수정한 레이아웃을 감지한다.
*/
public function test_has_modified_layouts_detects_user_modification(): void
{
$moduleIdentifier = 'test-modified-module';
Module::create([
'identifier' => $moduleIdentifier,
'vendor' => 'test',
'name' => ['ko' => '수정 테스트', 'en' => 'Modified Test'],
'version' => '1.0.0',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트용', 'en' => 'For testing'],
]);
$originalData = ['layout_name' => 'home', 'slots' => ['content' => []]];
$reflection = new \ReflectionClass($this->moduleManager);
$method = $reflection->getMethod('registerLayoutToTemplate');
$method->setAccessible(true);
$method->invoke($this->moduleManager, $this->adminTemplate, $moduleIdentifier.'.home', $originalData, $moduleIdentifier);
// 사용자 UI 에서 content 만 수정 (hash 는 그대로 유지)
TemplateLayout::where('source_identifier', $moduleIdentifier)
->update(['content' => ['layout_name' => 'home', 'slots' => ['content' => [['component' => 'Div', 'props' => ['className' => 'user-added']]]]]]);
$result = $this->moduleManager->hasModifiedLayouts($moduleIdentifier);
$this->assertTrue($result['has_modified_layouts']);
$this->assertEquals(1, $result['modified_count']);
$this->assertCount(1, $result['modified_layouts']);
$this->assertEquals($moduleIdentifier.'.home', $result['modified_layouts'][0]['name']);
}
/**
* refreshModuleLayouts 호출 시 레이아웃이 변경되면 캐시 버전이 증가하는지 테스트
*/
public function test_refresh_module_layouts_increments_cache_version_on_change(): void
{
// 초기 캐시 버전 설정
Cache::put('extension_cache_version', 1000);
Cache::put('g7:core:ext.cache_version', 1000);
// 테스트용 모듈 생성
$module = Module::create([
@@ -551,7 +666,7 @@ class ModuleManagerLayoutTest extends TestCase
$method->invoke($this->moduleManager);
$afterTime = time();
$newVersion = Cache::get('extension_cache_version');
$newVersion = Cache::get('g7:core:ext.cache_version');
$this->assertGreaterThanOrEqual($beforeTime, $newVersion);
$this->assertLessThanOrEqual($afterTime, $newVersion);
}
@@ -680,11 +795,11 @@ class ModuleManagerLayoutTest extends TestCase
public function test_cache_version_not_incremented_when_no_layout_changes(): void
{
$initialVersion = 1000;
Cache::put('extension_cache_version', $initialVersion);
Cache::put('g7:core:ext.cache_version', $initialVersion);
// 변경 없는 상태에서는 incrementExtensionCacheVersion이 호출되지 않으므로
// 캐시 버전이 유지됨
$currentVersion = Cache::get('extension_cache_version');
$currentVersion = Cache::get('g7:core:ext.cache_version');
$this->assertEquals($initialVersion, $currentVersion);
}
@@ -372,6 +372,48 @@ class ModuleManagerUpdateTest extends TestCase
$this->assertTrue(File::exists($activePath.'/module.json'));
}
/**
* copyFromPendingOrBundled($force=true) 가 활성 디렉토리가 존재해도 원본으로 덮어쓰는지 테스트.
*
* 불완전 설치 복구 시나리오: 활성 디렉토리에 일부 파일만 남아있을 때
* --force 로 원본(_bundled/_pending)에서 재복사하여 manifest 포함 전체를 복원한다.
*/
public function test_copy_from_pending_or_bundled_overwrites_when_force_is_true(): void
{
$method = new \ReflectionMethod($this->manager, 'copyFromPendingOrBundled');
$method->setAccessible(true);
// 불완전 활성 디렉토리: 일부 파일만 있고 manifest 없음
$activePath = $this->modulesPath.'/test-bundled-mod';
File::ensureDirectoryExists($activePath);
File::put($activePath.'/stale.txt', 'leftover from broken update');
$this->assertFalse(File::exists($activePath.'/module.json'), '시작 상태: manifest 없음');
// force=true 로 호출
$method->invoke($this->manager, 'test-bundled-mod', null, true);
// 원본으로 덮어써서 manifest 복원됨
$this->assertTrue(File::exists($activePath.'/module.json'), 'force=true 시 manifest 복원되어야 함');
}
/**
* force=false (기본) 시 활성 디렉토리 존재하면 복사 스킵 (기존 동작 유지 회귀 테스트).
*/
public function test_copy_from_pending_or_bundled_preserves_active_when_force_is_false(): void
{
$activePath = $this->modulesPath.'/test-bundled-mod';
File::ensureDirectoryExists($activePath);
File::put($activePath.'/custom.txt', 'user content');
$method = new \ReflectionMethod($this->manager, 'copyFromPendingOrBundled');
$method->setAccessible(true);
$method->invoke($this->manager, 'test-bundled-mod', null, false);
// 기본 동작: 활성 디렉토리 유지 (커스텀 파일 보존, manifest 복사 안 됨)
$this->assertTrue(File::exists($activePath.'/custom.txt'));
$this->assertEquals('user content', File::get($activePath.'/custom.txt'));
}
/**
* 상태 가드가 updating 상태에서 updateModule을 차단하는지 테스트
*/
@@ -0,0 +1,65 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\ModuleManager;
use Mockery;
use Tests\TestCase;
/**
* ModuleManager::runUpgradeSteps() 의 onStep 콜백 호출 검증.
*
* CLI UpdateModuleCommand 에서 progress bar clear → line → display 사이클을 만들기 위해
* 각 upgrade step 버전마다 콜백이 호출되어야 한다. runUpgradeSteps 는 protected 이므로
* Reflection 으로 직접 호출한다.
*/
class ModuleManagerUpgradeStepTest extends TestCase
{
public function test_run_upgrade_steps_invokes_on_step_for_each_version(): void
{
$moduleManager = app(ModuleManager::class);
// Module mock: upgrades() 가 2 개 step 반환
$module = Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
$module->shouldReceive('upgrades')->andReturn([
'1.1.0' => function () { /* no-op */ },
'1.2.0' => function () { /* no-op */ },
]);
$capturedVersions = [];
$onStep = function (string $version) use (&$capturedVersions): void {
$capturedVersions[] = $version;
};
$reflection = new \ReflectionClass($moduleManager);
$method = $reflection->getMethod('runUpgradeSteps');
$method->setAccessible(true);
// $fromVersion=1.0.0, $toVersion=1.2.0 → 1.1.0, 1.2.0 실행
$method->invoke($moduleManager, $module, '1.0.0', '1.2.0', false, $onStep);
$this->assertEquals(['1.1.0', '1.2.0'], $capturedVersions, '각 step 버전이 콜백에 전달되어야 함');
}
/**
* onStep 이 null 이어도 정상 동작 (하위 호환).
*/
public function test_run_upgrade_steps_tolerates_null_on_step(): void
{
$moduleManager = app(ModuleManager::class);
$module = Mockery::mock(\App\Contracts\Extension\ModuleInterface::class);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
$module->shouldReceive('upgrades')->andReturn([
'1.1.0' => function () { /* no-op */ },
]);
$reflection = new \ReflectionClass($moduleManager);
$method = $reflection->getMethod('runUpgradeSteps');
$method->setAccessible(true);
$this->expectNotToPerformAssertions();
$method->invoke($moduleManager, $module, '1.0.0', '1.1.0', false, null);
}
}
+20 -5
View File
@@ -156,9 +156,14 @@ class ModuleSeederTest extends TestCase
}
/**
* sirsoft-ecommerce 모듈의 getSeeders()가 SequenceSeeder만 반환하는지 테스트합니다.
* sirsoft-ecommerce 모듈의 getSeeders()가 필수 시더를 포함하는지 테스트합니다.
*
* 2026-02-09 최초 작성 당시에는 SequenceSeeder 1개만 반환했으나,
* 2026-03-03 배송사 관리 기능 추가(#19) 이후 ShippingCarrierSeeder 등이
* 설치 시 실행되는 필수 시더로 추가되었다. 본 테스트는 설치 시 반드시 실행되어야
* 하는 시더들이 모두 포함되어 있는지 검증한다.
*/
public function test_ecommerce_module_get_seeders_returns_sequence_seeder_only(): void
public function test_ecommerce_module_get_seeders_includes_required_seeders(): void
{
$moduleManager = app(ModuleManager::class);
$moduleManager->loadModules();
@@ -171,10 +176,20 @@ class ModuleSeederTest extends TestCase
$seeders = $module->getSeeders();
$this->assertCount(1, $seeders);
$this->assertEquals(
// SequenceSeeder 는 주문/상품 시퀀스 초기값 생성을 위해 반드시 포함되어야 함
$this->assertContains(
'Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\SequenceSeeder',
$seeders[0]
$seeders,
'SequenceSeeder 는 설치 시 필수 시더로 포함되어야 합니다'
);
// 모든 시더가 sirsoft-ecommerce 네임스페이스에 속해야 함
foreach ($seeders as $seederClass) {
$this->assertStringStartsWith(
'Modules\\Sirsoft\\Ecommerce\\Database\\Seeders\\',
$seederClass,
"시더 {$seederClass} 는 sirsoft-ecommerce 네임스페이스에 속해야 합니다"
);
}
}
}
@@ -0,0 +1,232 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Models\NotificationDefinition;
use App\Models\NotificationTemplate;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* NotificationSyncHelper 의 cleanupStale* 회귀 방지 테스트.
*
* 정책:
* - seeder 에 없는 (extension_type + extension_identifier + type) 조합의 definition 삭제
* - definition 삭제 시 FK cascade 로 연관 template 도 자동 정리
* - definition 은 유지되지만 channel 이 재구성된 경우: 제거된 channel 의 template 삭제
* - user_overrides 무관 삭제
*/
class NotificationSyncHelperTest extends TestCase
{
use RefreshDatabase;
private NotificationSyncHelper $helper;
protected function setUp(): void
{
parent::setUp();
$this->helper = app(NotificationSyncHelper::class);
}
public function test_cleanupStaleDefinitions_deletes_definitions_not_in_seeder_scope(): void
{
NotificationDefinition::create([
'type' => 'in-seeder',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '유지'],
'description' => ['ko' => '유지'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationDefinition::create([
'type' => 'not-in-seeder',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '삭제'],
'description' => ['ko' => '삭제'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStaleDefinitions(
'core',
'core',
['in-seeder'],
);
$this->assertSame(1, $deleted);
$this->assertDatabaseHas('notification_definitions', ['type' => 'in-seeder']);
$this->assertDatabaseMissing('notification_definitions', ['type' => 'not-in-seeder']);
}
public function test_cleanupStaleDefinitions_deletes_even_with_user_overrides(): void
{
NotificationDefinition::create([
'type' => 'stale-with-overrides',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '유저 수정 있음'],
'description' => ['ko' => '삭제 대상'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
'user_overrides' => ['name'],
]);
$deleted = $this->helper->cleanupStaleDefinitions('core', 'core', []);
$this->assertSame(1, $deleted);
$this->assertDatabaseMissing('notification_definitions', ['type' => 'stale-with-overrides']);
}
public function test_cleanupStaleDefinitions_scope_isolation_protects_other_extensions(): void
{
NotificationDefinition::create([
'type' => 'core-type',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => 'core'],
'description' => ['ko' => 'core'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationDefinition::create([
'type' => 'module-type',
'hook_prefix' => 'core.auth',
'extension_type' => 'module',
'extension_identifier' => 'sirsoft-board',
'name' => ['ko' => 'module'],
'description' => ['ko' => 'module'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStaleDefinitions('core', 'core', []);
$this->assertSame(1, $deleted, '코어 scope 만 삭제, 모듈 scope 는 보호');
$this->assertDatabaseMissing('notification_definitions', ['type' => 'core-type']);
$this->assertDatabaseHas('notification_definitions', ['type' => 'module-type']);
}
public function test_cleanupStaleTemplates_deletes_by_channel_diff(): void
{
$def = NotificationDefinition::create([
'type' => 'with-templates',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '정의'],
'description' => ['ko' => '정의'],
'channels' => ['mail', 'database'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationTemplate::create([
'definition_id' => $def->id,
'channel' => 'mail',
'subject' => ['ko' => '유지'],
'body' => ['ko' => '유지'],
'is_active' => true,
'is_default' => true,
]);
NotificationTemplate::create([
'definition_id' => $def->id,
'channel' => 'fcm',
'subject' => ['ko' => '삭제'],
'body' => ['ko' => '삭제'],
'is_active' => true,
'is_default' => true,
]);
$deleted = $this->helper->cleanupStaleTemplates($def->id, ['mail']);
$this->assertSame(1, $deleted);
$this->assertDatabaseHas('notification_templates', ['definition_id' => $def->id, 'channel' => 'mail']);
$this->assertDatabaseMissing('notification_templates', ['definition_id' => $def->id, 'channel' => 'fcm']);
}
public function test_cleanupStaleTemplates_deletes_even_with_user_overrides(): void
{
$def = NotificationDefinition::create([
'type' => 'tpl-overrides-test',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '정의'],
'description' => ['ko' => '정의'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
NotificationTemplate::create([
'definition_id' => $def->id,
'channel' => 'fcm',
'subject' => ['ko' => '유저 수정 있음'],
'body' => ['ko' => '삭제 대상'],
'is_active' => true,
'is_default' => true,
'user_overrides' => ['subject', 'body'],
]);
$deleted = $this->helper->cleanupStaleTemplates($def->id, ['mail']);
$this->assertSame(1, $deleted);
$this->assertDatabaseMissing('notification_templates', ['definition_id' => $def->id, 'channel' => 'fcm']);
}
public function test_definition_delete_cascades_to_templates(): void
{
$def = NotificationDefinition::create([
'type' => 'cascade-test',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => 'cascade'],
'description' => ['ko' => 'cascade'],
'channels' => ['mail'],
'hooks' => [],
'variables' => [],
'is_active' => true,
'is_default' => true,
]);
$tpl = NotificationTemplate::create([
'definition_id' => $def->id,
'channel' => 'mail',
'subject' => ['ko' => 'cascade'],
'body' => ['ko' => 'cascade'],
'is_active' => true,
'is_default' => true,
]);
$this->helper->cleanupStaleDefinitions('core', 'core', []);
$this->assertDatabaseMissing('notification_definitions', ['id' => $def->id]);
$this->assertDatabaseMissing('notification_templates', ['id' => $tpl->id]);
}
}
@@ -44,16 +44,16 @@ use App\Extension\AbstractPlugin;
class Plugin extends AbstractPlugin
{
public function getDependencies(): array
{
return ['sirsoft-ecommerce' => '>=1.0.0'];
}
}
PHP);
File::put($this->stubPluginDir.'/plugin.json', json_encode([
'identifier' => 'test-depcheck',
'version' => '1.0.0',
'name' => ['ko' => '의존성 테스트', 'en' => 'Dep Test'],
'dependencies' => [
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
'plugins' => [],
],
]));
}
@@ -157,12 +157,14 @@ PHP);
}
/**
* enrichDependencies가 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
* (버전 문자열이 아닌 실제 identifier)
* enrichDependencies가 중첩 모듈 의존성의 identifier를 올바르게 반환하는지 테스트합니다.
*
* manifest(plugin.json)의 dependencies 필드는 중첩 구조
* ['modules' => [...], 'plugins' => [...]] 로 저장되므로 enrichDependencies는
* 해당 구조를 그대로 파싱합니다 (커밋 72156e6f0, 2026-02-23).
*/
public function test_enrich_dependencies_returns_module_identifier_not_version(): void
{
// 모듈 생성
Module::create([
'identifier' => 'sirsoft-ecommerce',
'vendor' => 'sirsoft',
@@ -172,25 +174,24 @@ PHP);
'description' => ['ko' => '이커머스 모듈', 'en' => 'Ecommerce module'],
]);
// protected 메서드 테스트를 위해 리플렉션 사용
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
$dependencies = ['sirsoft-ecommerce' => '>=1.0.0'];
$dependencies = [
'modules' => ['sirsoft-ecommerce' => '>=1.0.0'],
];
$result = $reflection->invoke($this->pluginManager, $dependencies);
$this->assertCount(1, $result);
$this->assertEquals('sirsoft-ecommerce', $result[0]['identifier']);
$this->assertEquals('module', $result[0]['type']);
// 버전 문자열이 identifier로 사용되지 않아야 함
$this->assertNotEquals('>=1.0.0', $result[0]['identifier']);
}
/**
* enrichDependencies가 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
* enrichDependencies가 중첩 플러그인 의존성도 올바르게 처리하는지 테스트합니다.
*/
public function test_enrich_dependencies_returns_plugin_identifier(): void
{
// 플러그인 생성
Plugin::create([
'identifier' => 'sirsoft-payment',
'vendor' => 'sirsoft',
@@ -202,7 +203,9 @@ PHP);
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
$dependencies = [
'plugins' => ['sirsoft-payment' => '>=1.0.0'],
];
$result = $reflection->invoke($this->pluginManager, $dependencies);
$this->assertCount(1, $result);
@@ -211,18 +214,37 @@ PHP);
}
/**
* enrichDependencies가 미등록 의존성을 unknown 타입으로 반환하는지 테스트합니다.
* enrichDependencies가 미등록 의존성의 identifier를 그대로 반환하는지 테스트합니다.
*
* DB에 없는 의존성은 name 필드에 identifier를 사용하지만 type은 여전히
* 선언된 구조(module/plugin)를 따릅니다.
*/
public function test_enrich_dependencies_returns_unknown_for_missing(): void
public function test_enrich_dependencies_returns_identifier_for_missing(): void
{
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
$dependencies = ['nonexistent-extension' => '>=1.0.0'];
$dependencies = [
'plugins' => ['nonexistent-extension' => '>=1.0.0'],
];
$result = $reflection->invoke($this->pluginManager, $dependencies);
$this->assertCount(1, $result);
$this->assertEquals('nonexistent-extension', $result[0]['identifier']);
$this->assertEquals('unknown', $result[0]['type']);
$this->assertEquals('nonexistent-extension', $result[0]['name']);
$this->assertEquals('plugin', $result[0]['type']);
}
/**
* enrichDependencies가 중첩 구조가 아닌 경우 빈 배열을 반환하는지 테스트합니다.
*/
public function test_enrich_dependencies_returns_empty_for_non_nested_structure(): void
{
$reflection = new \ReflectionMethod($this->pluginManager, 'enrichDependencies');
$dependencies = ['sirsoft-payment' => '>=1.0.0'];
$result = $reflection->invoke($this->pluginManager, $dependencies);
$this->assertCount(0, $result);
}
/**
@@ -243,7 +265,7 @@ PHP);
// 의존성이 있는 플러그인 Mock
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
$plugin->shouldReceive('getDependencies')
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
// protected 메서드 리플렉션
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
@@ -269,7 +291,7 @@ PHP);
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
$plugin->shouldReceive('getDependencies')
->andReturn(['sirsoft-ecommerce' => '>=1.0.0']);
->andReturn(['modules' => ['sirsoft-ecommerce' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
@@ -284,7 +306,7 @@ PHP);
{
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
$plugin->shouldReceive('getDependencies')
->andReturn(['nonexistent-module' => '>=1.0.0']);
->andReturn(['modules' => ['nonexistent-module' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
@@ -309,7 +331,7 @@ PHP);
$plugin = \Mockery::mock(\App\Contracts\Extension\PluginInterface::class);
$plugin->shouldReceive('getDependencies')
->andReturn(['sirsoft-payment' => '>=1.0.0']);
->andReturn(['plugins' => ['sirsoft-payment' => '>=1.0.0']]);
$reflection = new \ReflectionMethod($this->pluginManager, 'checkDependencies');
@@ -0,0 +1,147 @@
<?php
namespace Tests\Unit\Extension;
use App\Extension\HookManager;
use App\Repositories\JsonConfigRepository;
use App\Services\DriverRegistryService;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* PluginManager 드라이버 사용 중 경고 로직 테스트
*
* 플러그인 비활성화 시 해당 플러그인이 제공하는 드라이버가
* 현재 사용 중인지 확인하는 DriverRegistryService의 연동을 검증합니다.
*/
class PluginManagerDriverWarningTest extends TestCase
{
private DriverRegistryService $driverRegistry;
protected function setUp(): void
{
parent::setUp();
$this->driverRegistry = new DriverRegistryService();
}
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
/**
* 플러그인이 제공하는 드라이버가 없으면 빈 배열을 반환하는지 검증합니다.
*/
#[Test]
public function it_returns_empty_when_plugin_provides_no_drivers(): void
{
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-nonexistent');
$this->assertIsArray($result);
$this->assertEmpty($result);
}
/**
* 플러그인 드라이버가 등록되었지만 현재 선택되지 않은 경우 빈 배열을 반환하는지 검증합니다.
*/
#[Test]
public function it_returns_empty_when_plugin_driver_not_selected(): void
{
// 플러그인 드라이버 등록
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
$drivers[] = [
'id' => 'custom_api',
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
'provider' => 'sirsoft-custom_mail',
];
return $drivers;
});
// 현재 설정에서는 코어 드라이버(smtp)가 선택된 상태 → 플러그인 드라이버 미사용
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-custom_mail');
$this->assertEmpty($result);
}
/**
* 플러그인 드라이버가 등록되고 현재 선택된 경우 사용 중으로 반환하는지 검증합니다.
*/
#[Test]
public function it_detects_plugin_driver_in_use(): void
{
// 플러그인 드라이버 등록
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
$drivers[] = [
'id' => 'custom_api',
'label' => ['ko' => '커스텀 API', 'en' => 'Custom API'],
'provider' => 'sirsoft-custom_mail',
];
return $drivers;
});
// JsonConfigRepository에서 custom_api가 선택된 상태로 mock
$mockConfigRepo = $this->createMock(JsonConfigRepository::class);
$mockConfigRepo->method('getCategory')
->willReturnCallback(function (string $category) {
if ($category === 'mail') {
return ['mailer' => 'custom_api'];
}
return [];
});
$this->app->instance(JsonConfigRepository::class, $mockConfigRepo);
$result = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-custom_mail');
$this->assertCount(1, $result);
$this->assertEquals('mail', $result[0]['category']);
$this->assertEquals('custom_api', $result[0]['driver_id']);
}
/**
* 다른 플러그인의 드라이버는 감지하지 않는지 검증합니다.
*/
#[Test]
public function it_only_detects_drivers_from_specified_plugin(): void
{
// 두 개의 플러그인이 각각 드라이버 등록
HookManager::addFilter('core.settings.available_mail_drivers', function (array $drivers) {
$drivers[] = [
'id' => 'plugin_a_mailer',
'label' => ['ko' => 'A 메일러', 'en' => 'A Mailer'],
'provider' => 'sirsoft-plugin_a',
];
$drivers[] = [
'id' => 'plugin_b_mailer',
'label' => ['ko' => 'B 메일러', 'en' => 'B Mailer'],
'provider' => 'sirsoft-plugin_b',
];
return $drivers;
});
// plugin_a_mailer가 선택된 상태
$mockConfigRepo = $this->createMock(JsonConfigRepository::class);
$mockConfigRepo->method('getCategory')
->willReturnCallback(function (string $category) {
if ($category === 'mail') {
return ['mailer' => 'plugin_a_mailer'];
}
return [];
});
$this->app->instance(JsonConfigRepository::class, $mockConfigRepo);
// plugin_a는 감지, plugin_b는 미감지
$resultA = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-plugin_a');
$this->assertCount(1, $resultA);
$resultB = $this->driverRegistry->getPluginProvidedDriversInUse('sirsoft-plugin_b');
$this->assertEmpty($resultB);
}
}
@@ -242,7 +242,7 @@ class TemplateManagerOverrideTest extends TestCase
]);
// 캐시 설정
$cacheKey = "template.{$template->id}.layout.{$override->name}";
$cacheKey = "g7:core:template.{$template->id}.layout.{$override->name}";
Cache::put($cacheKey, $override->content, 3600);
$this->assertNotNull(Cache::get($cacheKey));
@@ -448,7 +448,7 @@ class TemplateManagerOverrideTest extends TestCase
public function test_increment_extension_cache_version(): void
{
// 초기 캐시 버전 설정
Cache::put('extension_cache_version', 1000);
Cache::put('g7:core:ext.cache_version', 1000);
$reflection = new \ReflectionClass($this->templateManager);
$method = $reflection->getMethod('incrementExtensionCacheVersion');
@@ -458,7 +458,7 @@ class TemplateManagerOverrideTest extends TestCase
$method->invoke($this->templateManager);
$afterTime = time();
$newVersion = Cache::get('extension_cache_version');
$newVersion = Cache::get('g7:core:ext.cache_version');
$this->assertGreaterThanOrEqual($beforeTime, $newVersion);
$this->assertLessThanOrEqual($afterTime, $newVersion);
}
@@ -469,7 +469,7 @@ class TemplateManagerOverrideTest extends TestCase
public function test_get_extension_cache_version(): void
{
$expectedVersion = 1735000000;
Cache::put('extension_cache_version', $expectedVersion);
Cache::put('g7:core:ext.cache_version', $expectedVersion);
// ClearsTemplateCaches trait의 정적 메서드 호출
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
@@ -482,7 +482,7 @@ class TemplateManagerOverrideTest extends TestCase
*/
public function test_get_extension_cache_version_returns_zero_when_not_set(): void
{
Cache::forget('extension_cache_version');
Cache::forget('g7:core:ext.cache_version');
$version = \App\Extension\Traits\ClearsTemplateCaches::getExtensionCacheVersion();
@@ -650,7 +650,7 @@ class TemplateManagerOverrideTest extends TestCase
]);
// 캐시 설정
$cacheKey = "template.{$template->id}.layout.{$orphanName}";
$cacheKey = "g7:core:template.{$template->id}.layout.{$orphanName}";
Cache::put($cacheKey, ['cached' => true], 3600);
$this->assertNotNull(Cache::get($cacheKey));
+100
View File
@@ -218,6 +218,106 @@ class UpgradeStepsTest extends TestCase
$this->assertTrue(true);
}
/**
* force + 동일 버전: 해당 버전의 스텝이 실행되는지 테스트
*/
public function test_run_upgrade_steps_force_with_same_version_executes_step(): void
{
$executedSteps = [];
$step = Mockery::mock(UpgradeStepInterface::class);
$step->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
$executedSteps[] = $ctx->currentStep;
});
$otherStep = Mockery::mock(UpgradeStepInterface::class);
$otherStep->shouldNotReceive('run'); // 다른 버전은 실행되지 않아야 함
$module = Mockery::mock(ModuleInterface::class);
$module->shouldReceive('upgrades')->once()->andReturn([
'1.0.0' => $step,
'1.1.0' => $otherStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
$method->setAccessible(true);
$method->invoke($manager, $module, '1.0.0', '1.0.0', true);
$this->assertEquals(['1.0.0'], $executedSteps);
}
/**
* force + 프리릴리스 동일 버전: 해당 버전의 스텝이 실행되는지 테스트
*/
public function test_run_upgrade_steps_force_with_same_prerelease_version(): void
{
$executedSteps = [];
$step_beta2 = Mockery::mock(UpgradeStepInterface::class);
$step_beta2->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
$executedSteps[] = $ctx->currentStep;
});
$step_beta1 = Mockery::mock(UpgradeStepInterface::class);
$step_beta1->shouldNotReceive('run');
$module = Mockery::mock(ModuleInterface::class);
$module->shouldReceive('upgrades')->once()->andReturn([
'1.0.0-beta.1' => $step_beta1,
'1.0.0-beta.2' => $step_beta2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
$method->setAccessible(true);
$method->invoke($manager, $module, '1.0.0-beta.2', '1.0.0-beta.2', true);
$this->assertEquals(['1.0.0-beta.2'], $executedSteps);
}
/**
* force + 다른 버전 범위: 기존 범위 필터링이 정상 동작하는지 테스트
*/
public function test_run_upgrade_steps_force_with_different_versions_uses_normal_range(): void
{
$executedSteps = [];
$step_1_1 = Mockery::mock(UpgradeStepInterface::class);
$step_1_1->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
$executedSteps[] = $ctx->currentStep;
});
$step_1_0 = Mockery::mock(UpgradeStepInterface::class);
$step_1_0->shouldNotReceive('run'); // fromVersion 이하
$step_1_2 = Mockery::mock(UpgradeStepInterface::class);
$step_1_2->shouldReceive('run')->once()->andReturnUsing(function (UpgradeContext $ctx) use (&$executedSteps) {
$executedSteps[] = $ctx->currentStep;
});
$module = Mockery::mock(ModuleInterface::class);
$module->shouldReceive('upgrades')->once()->andReturn([
'1.0.0' => $step_1_0,
'1.1.0' => $step_1_1,
'1.2.0' => $step_1_2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
$method->setAccessible(true);
// force=true이지만 fromVersion != toVersion이므로 일반 범위 필터링 적용
$method->invoke($manager, $module, '1.0.0', '1.2.0', true);
$this->assertEquals(['1.1.0', '1.2.0'], $executedSteps);
}
/**
* 업그레이드 스텝 파일명에서 일반 버전을 올바르게 파싱합니다.
*/
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Extension\Vendor;
use App\Extension\Vendor\EnvironmentDetector;
use Tests\TestCase;
class EnvironmentDetectorTest extends TestCase
{
private EnvironmentDetector $detector;
protected function setUp(): void
{
parent::setUp();
$this->detector = new EnvironmentDetector;
}
public function test_has_proc_open_reflects_function_availability(): void
{
$result = $this->detector->hasProcOpen();
$this->assertIsBool($result);
// proc_open 은 disable_functions 로 차단되지 않은 일반 테스트 환경에서는 true
if (function_exists('proc_open')) {
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
if (! in_array('proc_open', $disabled, true)) {
$this->assertTrue($result);
}
}
}
public function test_has_zip_archive_returns_true_when_class_exists(): void
{
$this->assertSame(class_exists(\ZipArchive::class), $this->detector->hasZipArchive());
}
public function test_find_composer_binary_returns_null_when_no_candidate_available(): void
{
$detector = new EnvironmentDetector;
$detector->resetCache();
// 절대 존재하지 않는 경로를 hint로 주고 ENV/config도 비운 상태 가정
// (실제 환경에 composer가 설치되어 있으면 PATH 검색 단계에서 발견될 수 있음)
$original = config('process.composer_binary');
config(['process.composer_binary' => null]);
try {
$result = $detector->findComposerBinary('/nonexistent/path/to/composer');
// 반환값은 실제 PATH에 composer가 있느냐에 따라 달라짐 — null 또는 문자열
$this->assertTrue($result === null || is_string($result));
} finally {
config(['process.composer_binary' => $original]);
}
}
public function test_find_composer_binary_accepts_hint_with_spaces(): void
{
$detector = new EnvironmentDetector;
$detector->resetCache();
// 공백 포함 힌트 — 파일 존재 검사 스킵하고 그대로 사용
$hint = 'php /custom/path/composer.phar';
$result = $detector->findComposerBinary($hint);
$this->assertSame($hint, $result);
}
public function test_summarize_returns_complete_report(): void
{
$report = $this->detector->summarize();
$this->assertArrayHasKey('proc_open', $report);
$this->assertArrayHasKey('shell_exec', $report);
$this->assertArrayHasKey('zip_archive', $report);
$this->assertArrayHasKey('composer_binary', $report);
$this->assertArrayHasKey('composer_executable', $report);
$this->assertArrayHasKey('can_use_composer', $report);
$this->assertArrayHasKey('can_use_bundle', $report);
$this->assertIsBool($report['proc_open']);
$this->assertIsBool($report['zip_archive']);
$this->assertIsBool($report['can_use_composer']);
$this->assertIsBool($report['can_use_bundle']);
}
public function test_reset_cache_clears_cached_values(): void
{
$this->detector->canExecuteComposer();
$this->detector->resetCache();
// 재호출 시 예외 없이 동작해야 함
$result = $this->detector->canExecuteComposer();
$this->assertIsBool($result);
}
}
@@ -0,0 +1,159 @@
<?php
namespace Tests\Unit\Extension\Vendor;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorBundleInstaller;
use App\Extension\Vendor\VendorBundler;
use App\Extension\Vendor\VendorIntegrityChecker;
use App\Extension\Vendor\VendorMode;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class VendorBundleInstallerTest extends TestCase
{
private VendorBundleInstaller $installer;
private string $testDir;
protected function setUp(): void
{
parent::setUp();
$this->installer = new VendorBundleInstaller(new VendorIntegrityChecker);
$this->testDir = storage_path('app/test-vendor-installer-'.uniqid());
File::ensureDirectoryExists($this->testDir);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testDir)) {
File::deleteDirectory($this->testDir);
}
parent::tearDown();
}
/**
* 테스트용 vendor-bundle.zip + manifest 생성.
*/
private function createValidBundle(array $files = ['vendor/test/pkg/file.php' => '<?php // test']): void
{
$zipPath = $this->testDir.'/vendor-bundle.zip';
$zip = new \ZipArchive;
$this->assertTrue($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true);
foreach ($files as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
$manifest = [
'schema_version' => VendorBundler::SCHEMA_VERSION,
'generated_at' => date('c'),
'generator' => 'test',
'target' => 'test',
'zip_sha256' => hash_file('sha256', $zipPath),
'zip_size' => filesize($zipPath),
'package_count' => 1,
'packages' => [['name' => 'test/pkg', 'version' => '1.0.0', 'type' => 'library']],
];
File::put($this->testDir.'/vendor-bundle.json', json_encode($manifest));
}
public function test_install_throws_when_zip_missing(): void
{
$this->expectException(VendorInstallException::class);
$this->installer->install($this->testDir, $this->testDir);
}
public function test_install_extracts_zip_successfully(): void
{
$this->createValidBundle();
$result = $this->installer->install($this->testDir, $this->testDir);
$this->assertSame(VendorMode::Bundled, $result->mode);
$this->assertSame('bundled', $result->strategy);
$this->assertSame(1, $result->packageCount);
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
}
public function test_install_throws_on_hash_mismatch(): void
{
$this->createValidBundle();
// manifest 조작 — 잘못된 해시
$manifest = json_decode(file_get_contents($this->testDir.'/vendor-bundle.json'), true);
$manifest['zip_sha256'] = hash('sha256', 'tampered');
File::put($this->testDir.'/vendor-bundle.json', json_encode($manifest));
$this->expectException(VendorInstallException::class);
$this->installer->install($this->testDir, $this->testDir);
}
public function test_install_rejects_unsafe_paths(): void
{
// zip slip 시도
$zipPath = $this->testDir.'/vendor-bundle.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('../evil.php', '<?php // evil');
$zip->close();
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => VendorBundler::SCHEMA_VERSION,
'zip_sha256' => hash_file('sha256', $zipPath),
'package_count' => 0,
]));
$this->expectException(VendorInstallException::class);
$this->installer->install($this->testDir, $this->testDir);
}
public function test_install_backs_up_existing_vendor_and_deletes_on_success(): void
{
// 기존 vendor/ 배치
File::ensureDirectoryExists($this->testDir.'/vendor/old');
File::put($this->testDir.'/vendor/old/marker.txt', 'old vendor');
$this->createValidBundle();
$this->installer->install($this->testDir, $this->testDir);
// 새 vendor/test/pkg/file.php 존재
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
// 기존 vendor/old/ 는 zip 추출에 의해 덮어써지지 않으므로 제거되지 않음
// (백업에서 복원되지는 않으나, 이는 zip 이 해당 경로를 포함하지 않기 때문)
// 중요: 백업 디렉토리 (vendor/.bundle_backup_*) 는 성공 시 삭제되어야 함
$backups = glob($this->testDir.'/vendor/.bundle_backup_*');
$this->assertEmpty($backups, '성공 시 백업 디렉토리는 삭제되어야 함');
// targetDir (최상위) 에는 vendor.old.* 가 절대 생성되지 않아야 함 (공유 호스팅 호환)
$legacyBackups = glob($this->testDir.'/vendor.old.*');
$this->assertEmpty($legacyBackups, 'targetDir 에 legacy vendor.old.* 디렉토리가 생성되면 안 됨');
}
public function test_install_succeeds_when_only_vendor_dir_is_writable(): void
{
// 공유 호스팅 시나리오 재현: targetDir 은 있지만 vendor/ 만 쓰기 가능한 환경.
// 실제 권한 변경은 Windows 에서 검증 어려우므로, 대신 새 구조의 동작 원리를 검증:
// vendor/ 가 이미 존재하고 zip 추출이 vendor/ 내부 항목만 조작하는지 확인.
File::ensureDirectoryExists($this->testDir.'/vendor');
File::put($this->testDir.'/vendor/legacy.txt', 'preexisting');
$this->createValidBundle();
$result = $this->installer->install($this->testDir, $this->testDir);
$this->assertSame('bundled', $result->strategy);
$this->assertFileExists($this->testDir.'/vendor/test/pkg/file.php');
// 백업 디렉토리는 성공 후 정리되어야 함
$backups = glob($this->testDir.'/vendor/.bundle_backup_*');
$this->assertEmpty($backups);
// targetDir 루트에는 어떠한 백업/임시 디렉토리도 만들어지지 않아야 함
// (vendor/ 내부 백업 방식이므로 루트는 오염되지 않음)
$topLevelEntries = array_filter(scandir($this->testDir), fn ($e) => ! in_array($e, ['.', '..', 'vendor', 'vendor-bundle.zip', 'vendor-bundle.json'], true));
$this->assertEmpty($topLevelEntries, 'targetDir 루트가 오염되지 않아야 함');
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
namespace Tests\Unit\Extension\Vendor;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorBundler;
use App\Extension\Vendor\VendorIntegrityChecker;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class VendorBundlerTest extends TestCase
{
private VendorBundler $bundler;
private VendorIntegrityChecker $checker;
private string $testDir;
protected function setUp(): void
{
parent::setUp();
$this->checker = new VendorIntegrityChecker;
$this->bundler = new VendorBundler($this->checker);
// 테스트는 실제 composer 실행 없이 가짜 vendor/ 구조를 스테이징에 생성.
// 이는 실제 빌드 흐름(스테이징 → composer install → zip)을 시뮬레이션하되,
// 외부 네트워크/composer 의존 없이 단위 테스트 속도를 유지하기 위함.
$this->bundler->setComposerInstallRunner(function (string $stagingDir): void {
File::ensureDirectoryExists($stagingDir.'/vendor/test/lib');
File::put($stagingDir.'/vendor/test/lib/file.php', '<?php // test');
File::put($stagingDir.'/vendor/autoload.php', '<?php // autoload');
});
$this->testDir = storage_path('app/test-vendor-bundler-'.uniqid());
File::ensureDirectoryExists($this->testDir);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testDir)) {
File::deleteDirectory($this->testDir);
}
parent::tearDown();
}
/**
* 테스트용 composer 프로젝트 구조 생성.
*
* 스테이징 기반 빌드에서는 소스 디렉토리에 vendor/ 가 없어도 되고,
* composer install 은 setUp() 의 주입된 runner 가 대신 처리한다.
* 따라서 소스에는 composer.json + composer.lock 만 배치한다.
*/
private function createFakeProject(): void
{
File::put($this->testDir.'/composer.json', json_encode([
'name' => 'test/project',
// VendorBundler 는 php/ext-* 만 있는 확장을 skip 하므로 외부 의존성 선언 필수
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
]));
File::put($this->testDir.'/composer.lock', json_encode([
'packages' => [
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
['name' => 'test/other', 'version' => '2.0.0', 'type' => 'library'],
],
]));
}
public function test_build_throws_when_composer_json_missing(): void
{
$this->expectException(VendorInstallException::class);
// buildForCore의 내부 build()에 도달하는 private 테스트는 어려우므로
// buildForExtension 을 통해 간접적으로 검증
$this->bundler->buildForExtension('module', 'nonexistent-module-test');
}
/**
* build() private 메서드 호출 헬퍼.
*
* 테스트는 단일 testDir 을 소스/출력 동일하게 사용 (코어 케이스와 유사).
*/
private function invokeBuild(string $target, bool $force = false, ?string $sourcePath = null, ?string $outputPath = null): \App\Extension\Vendor\VendorBundleResult
{
$reflection = new \ReflectionClass($this->bundler);
$method = $reflection->getMethod('build');
$method->setAccessible(true);
return $method->invoke(
$this->bundler,
$sourcePath ?? $this->testDir,
$outputPath ?? $this->testDir,
$target,
$force,
);
}
public function test_build_creates_zip_and_manifest(): void
{
$this->createFakeProject();
$result = $this->invokeBuild('test:project');
$this->assertFalse($result->skipped);
$this->assertFileExists($this->testDir.'/vendor-bundle.zip');
$this->assertFileExists($this->testDir.'/vendor-bundle.json');
$this->assertSame(2, $result->packageCount);
$this->assertGreaterThan(0, $result->zipSize);
}
public function test_build_skips_when_up_to_date(): void
{
$this->createFakeProject();
// 1차 빌드
$first = $this->invokeBuild('test:project');
$this->assertFalse($first->skipped);
// 2차 빌드 — composer.json 변경 없음 → 스킵
$second = $this->invokeBuild('test:project');
$this->assertTrue($second->skipped);
$this->assertSame('up-to-date', $second->reason);
}
public function test_build_force_rebuilds_even_when_up_to_date(): void
{
$this->createFakeProject();
$this->invokeBuild('test:project');
$second = $this->invokeBuild('test:project', force: true);
$this->assertFalse($second->skipped);
}
public function test_is_stale_returns_true_when_no_bundle(): void
{
$this->createFakeProject();
$this->assertTrue($this->bundler->isStale($this->testDir));
}
public function test_is_stale_returns_false_after_build(): void
{
$this->createFakeProject();
$this->invokeBuild('test:project');
$this->assertFalse($this->bundler->isStale($this->testDir));
}
public function test_is_stale_returns_true_when_composer_json_changed(): void
{
$this->createFakeProject();
$this->invokeBuild('test:project');
// composer.json 변경
File::put($this->testDir.'/composer.json', json_encode([
'name' => 'test/project',
'require' => ['php' => '^8.2', 'new/pkg' => '^1.0'],
]));
$this->assertTrue($this->bundler->isStale($this->testDir));
}
public function test_built_manifest_has_valid_sha256_for_integrity_check(): void
{
$this->createFakeProject();
$this->invokeBuild('test:project');
$integrity = $this->checker->verify($this->testDir);
$this->assertTrue($integrity->valid, 'errors: '.implode(',', $integrity->errors));
}
/**
* php/ext-* 런타임 제약만 있는 확장은 skip 처리되고 예외가 발생하지 않아야 합니다.
*/
public function test_build_skips_when_no_external_dependencies(): void
{
// 외부 의존성 없는 composer.json (php 버전만)
File::put($this->testDir.'/composer.json', json_encode([
'name' => 'test/no-deps',
'require' => ['php' => '^8.2'],
]));
$result = $this->invokeBuild('test:no-deps');
$this->assertTrue($result->skipped);
$this->assertSame('no-external-dependencies', $result->reason);
$this->assertFileDoesNotExist($this->testDir.'/vendor-bundle.zip');
}
/**
* ext-* 확장만 요구하는 경우도 외부 의존성 없음으로 간주됩니다.
*/
public function test_build_skips_when_only_php_extensions_required(): void
{
File::put($this->testDir.'/composer.json', json_encode([
'name' => 'test/ext-only',
'require' => ['php' => '^8.2', 'ext-json' => '*', 'ext-zip' => '*'],
]));
$result = $this->invokeBuild('test:ext-only');
$this->assertTrue($result->skipped);
$this->assertSame('no-external-dependencies', $result->reason);
}
/**
* 분리 구조 — sourcePath 와 outputPath 가 다른 경우 (확장 케이스).
*
* _bundled 디렉토리에는 composer.json 만 있고, vendor/ 는 활성 디렉토리에만 있는 상황을 재현.
*/
public function test_build_writes_output_to_different_path_than_source(): void
{
$sourceDir = $this->testDir.'/active';
$outputDir = $this->testDir.'/bundled';
File::ensureDirectoryExists($sourceDir);
File::ensureDirectoryExists($outputDir);
// 활성 디렉토리 — composer.json + composer.lock (vendor/ 는 스테이징에서 생성)
File::put($sourceDir.'/composer.json', json_encode([
'name' => 'test/project',
'require' => ['php' => '^8.2', 'test/lib' => '^1.0'],
]));
File::put($sourceDir.'/composer.lock', json_encode([
'packages' => [
['name' => 'test/lib', 'version' => '1.0.0', 'type' => 'library'],
],
]));
// _bundled 디렉토리 — composer.json 만 (vendor/ 없음)
File::put($outputDir.'/composer.json', File::get($sourceDir.'/composer.json'));
$result = $this->invokeBuild('test:split', sourcePath: $sourceDir, outputPath: $outputDir);
$this->assertFalse($result->skipped);
// 출력이 outputDir 에만 존재해야 함 (sourceDir 오염 없음)
$this->assertFileExists($outputDir.'/vendor-bundle.zip');
$this->assertFileExists($outputDir.'/vendor-bundle.json');
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.zip');
$this->assertFileDoesNotExist($sourceDir.'/vendor-bundle.json');
}
/**
* 외부 의존성 없는 확장은 isStale 이 항상 false 를 반환해야 합니다.
*/
public function test_is_stale_returns_false_when_no_external_dependencies(): void
{
File::put($this->testDir.'/composer.json', json_encode([
'name' => 'test/no-deps',
'require' => ['php' => '^8.2'],
]));
$this->assertFalse($this->bundler->isStale($this->testDir));
}
/**
* 스테이징 기반 빌드 — 개발 vendor/ 가 dev 의존성을 포함해도 번들은 깨끗함.
*
* 회귀 방지: autoload_files.php 가 존재하지 않는 dev 패키지를 require 하는 버그
* (244 브랜치 shared hosting 실전 테스트에서 발견된 이슈).
*
* 주입된 runner 는 "clean --no-dev vendor/" 를 시뮬레이션하여 번들 내용이
* 스테이징 vendor/ 와 일치하는지 검증한다.
*/
public function test_build_uses_staging_vendor_not_source_vendor(): void
{
$this->createFakeProject();
// 소스 디렉토리에 dev 파일/디렉토리를 생성 — 번들에 포함되지 않아야 함
File::ensureDirectoryExists($this->testDir.'/vendor/fakerphp/faker');
File::put($this->testDir.'/vendor/fakerphp/faker/README.md', '# Faker dev package');
File::ensureDirectoryExists($this->testDir.'/vendor/myclabs/deep-copy');
File::put($this->testDir.'/vendor/myclabs/deep-copy/deep_copy.php', '<?php');
$this->invokeBuild('test:staging');
$zip = new \ZipArchive;
$this->assertTrue($zip->open($this->testDir.'/vendor-bundle.zip') === true);
$fileList = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$fileList[] = $zip->getNameIndex($i);
}
$zip->close();
// 스테이징 runner 가 생성한 test/lib 만 포함되어야 함
$this->assertTrue(
collect($fileList)->contains(fn ($f) => str_contains($f, 'vendor/test/lib')),
'runner 가 생성한 test/lib 은 포함되어야 합니다'
);
// 소스의 dev 디렉토리는 번들에 절대 포함되지 않아야 함
$this->assertFalse(
collect($fileList)->contains(fn ($f) => str_contains($f, 'fakerphp')),
'소스의 dev 패키지 fakerphp 는 번들에 포함되면 안 됩니다 (스테이징이 아닌 소스 사용 금지)'
);
$this->assertFalse(
collect($fileList)->contains(fn ($f) => str_contains($f, 'myclabs')),
'소스의 dev 패키지 myclabs 는 번들에 포함되면 안 됩니다'
);
}
/**
* composer 미설치 환경에서 명확한 예외 발생.
*
* EnvironmentDetector 가 composer 미실행으로 보고하도록 mock 을 주입하고,
* composerInstallRunner 도 null 로 초기화한 뒤 빌드 시도.
*/
public function test_build_throws_when_composer_not_available(): void
{
$detector = \Mockery::mock(\App\Extension\Vendor\EnvironmentDetector::class);
$detector->shouldReceive('canExecuteComposer')->andReturn(false);
$bundler = new VendorBundler($this->checker, $detector);
// 기본 runner (proc_open) 사용하도록 override 안 함
$this->createFakeProject();
$this->expectException(VendorInstallException::class);
$reflection = new \ReflectionClass($bundler);
$method = $reflection->getMethod('build');
$method->setAccessible(true);
$method->invoke($bundler, $this->testDir, $this->testDir, 'test:no-composer', false);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Tests\Unit\Extension\Vendor;
use App\Extension\Vendor\VendorBundler;
use App\Extension\Vendor\VendorIntegrityChecker;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class VendorIntegrityCheckerTest extends TestCase
{
private VendorIntegrityChecker $checker;
private string $testDir;
protected function setUp(): void
{
parent::setUp();
$this->checker = new VendorIntegrityChecker;
$this->testDir = storage_path('app/test-vendor-integrity-'.uniqid());
File::ensureDirectoryExists($this->testDir);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testDir)) {
File::deleteDirectory($this->testDir);
}
parent::tearDown();
}
public function test_verify_fails_when_zip_missing(): void
{
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('bundle_zip_missing', $result->errors);
}
public function test_verify_fails_when_manifest_missing(): void
{
File::put($this->testDir.'/vendor-bundle.zip', 'dummy zip content');
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('bundle_manifest_missing', $result->errors);
}
public function test_verify_fails_when_manifest_invalid_json(): void
{
File::put($this->testDir.'/vendor-bundle.zip', 'dummy');
File::put($this->testDir.'/vendor-bundle.json', '{invalid json');
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('bundle_manifest_invalid', $result->errors);
}
public function test_verify_fails_on_unsupported_schema_version(): void
{
File::put($this->testDir.'/vendor-bundle.zip', 'dummy');
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => '99.0',
'zip_sha256' => hash('sha256', 'dummy'),
]));
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('bundle_schema_unsupported', $result->errors);
}
public function test_verify_fails_on_zip_hash_mismatch(): void
{
File::put($this->testDir.'/vendor-bundle.zip', 'actual content');
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => VendorBundler::SCHEMA_VERSION,
'zip_sha256' => hash('sha256', 'different content'),
]));
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('zip_hash_mismatch', $result->errors);
}
public function test_verify_passes_on_matching_hash(): void
{
$content = 'matching zip content';
File::put($this->testDir.'/vendor-bundle.zip', $content);
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => VendorBundler::SCHEMA_VERSION,
'zip_sha256' => hash('sha256', $content),
'package_count' => 42,
]));
$result = $this->checker->verify($this->testDir);
$this->assertTrue($result->valid, 'errors: '.implode(',', $result->errors));
$this->assertEmpty($result->errors);
$this->assertSame(42, $result->meta['package_count']);
}
public function test_verify_detects_composer_json_mismatch(): void
{
$zipContent = 'zip content';
$composerJsonContent = '{"name":"new"}';
$storedHash = hash('sha256', '{"name":"old"}');
File::put($this->testDir.'/vendor-bundle.zip', $zipContent);
File::put($this->testDir.'/composer.json', $composerJsonContent);
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => VendorBundler::SCHEMA_VERSION,
'zip_sha256' => hash('sha256', $zipContent),
'composer_json_sha256' => $storedHash,
]));
$result = $this->checker->verify($this->testDir);
$this->assertFalse($result->valid);
$this->assertContains('composer_json_sha_mismatch', $result->errors);
}
public function test_read_manifest_returns_null_when_missing(): void
{
$this->assertNull($this->checker->readManifest($this->testDir));
}
public function test_read_manifest_returns_parsed_array(): void
{
File::put($this->testDir.'/vendor-bundle.json', json_encode(['key' => 'value']));
$result = $this->checker->readManifest($this->testDir);
$this->assertIsArray($result);
$this->assertSame('value', $result['key']);
}
public function test_compute_file_hash_returns_sha256(): void
{
$path = $this->testDir.'/test.txt';
File::put($path, 'hello');
$hash = $this->checker->computeFileHash($path);
$this->assertSame(hash('sha256', 'hello'), $hash);
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
namespace Tests\Unit\Extension\Vendor;
use App\Extension\Vendor\EnvironmentDetector;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorBundleInstaller;
use App\Extension\Vendor\VendorBundler;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorIntegrityChecker;
use App\Extension\Vendor\VendorMode;
use App\Extension\Vendor\VendorResolver;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
class VendorResolverTest extends TestCase
{
private string $testDir;
protected function setUp(): void
{
parent::setUp();
$this->testDir = storage_path('app/test-vendor-resolver-'.uniqid());
File::ensureDirectoryExists($this->testDir);
}
protected function tearDown(): void
{
if (File::isDirectory($this->testDir)) {
File::deleteDirectory($this->testDir);
}
parent::tearDown();
}
private function makeResolver(bool $canExecuteComposer): VendorResolver
{
$env = new class($canExecuteComposer) extends EnvironmentDetector
{
public function __construct(private bool $canExec) {}
public function canExecuteComposer(?string $hint = null): bool
{
return $this->canExec;
}
};
$checker = new VendorIntegrityChecker;
return new VendorResolver(
environmentDetector: $env,
integrityChecker: $checker,
bundleInstaller: new VendorBundleInstaller($checker),
);
}
private function createValidBundle(): void
{
$zipPath = $this->testDir.'/vendor-bundle.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$zip->addFromString('vendor/autoload.php', '<?php // test');
$zip->close();
File::put($this->testDir.'/vendor-bundle.json', json_encode([
'schema_version' => VendorBundler::SCHEMA_VERSION,
'zip_sha256' => hash_file('sha256', $zipPath),
'package_count' => 1,
]));
}
private function makeContext(VendorMode $requested = VendorMode::Auto, ?VendorMode $previous = null, string $operation = 'install'): VendorInstallContext
{
return new VendorInstallContext(
target: 'module',
identifier: 'test-mod',
sourceDir: $this->testDir,
targetDir: $this->testDir,
requestedMode: $requested,
previousMode: $previous,
operation: $operation,
);
}
public function test_resolves_explicit_composer_mode(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(requested: VendorMode::Composer);
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
}
public function test_resolves_explicit_bundled_mode(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(requested: VendorMode::Bundled);
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
}
public function test_auto_resolves_to_composer_when_available(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext();
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
}
public function test_auto_resolves_to_bundled_when_composer_unavailable_but_bundle_exists(): void
{
$this->createValidBundle();
$resolver = $this->makeResolver(canExecuteComposer: false);
$ctx = $this->makeContext();
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
}
public function test_auto_throws_when_neither_composer_nor_bundle_available(): void
{
$resolver = $this->makeResolver(canExecuteComposer: false);
$ctx = $this->makeContext();
$this->expectException(VendorInstallException::class);
$resolver->resolveMode($ctx);
}
public function test_update_inherits_previous_mode(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(
requested: VendorMode::Auto,
previous: VendorMode::Bundled,
operation: 'update',
);
$this->assertSame(VendorMode::Bundled, $resolver->resolveMode($ctx));
}
public function test_update_does_not_inherit_auto_previous_mode(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(
requested: VendorMode::Auto,
previous: VendorMode::Auto,
operation: 'update',
);
// auto → auto 해석 → composer 가능하므로 composer 반환
$this->assertSame(VendorMode::Composer, $resolver->resolveMode($ctx));
}
public function test_install_throws_when_composer_mode_but_executor_missing(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(requested: VendorMode::Composer);
$this->expectException(VendorInstallException::class);
$resolver->install($ctx, composerExecutor: null);
}
public function test_install_delegates_to_composer_executor_callback(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(requested: VendorMode::Composer);
$called = false;
$executor = function (VendorInstallContext $c) use (&$called) {
$called = true;
return new VendorInstallResult(
mode: VendorMode::Composer,
strategy: 'composer',
packageCount: 10,
);
};
$result = $resolver->install($ctx, $executor);
$this->assertTrue($called);
$this->assertSame(VendorMode::Composer, $result->mode);
$this->assertSame(10, $result->packageCount);
}
public function test_install_executes_bundle_strategy_end_to_end(): void
{
$this->createValidBundle();
$resolver = $this->makeResolver(canExecuteComposer: false);
$ctx = $this->makeContext(requested: VendorMode::Bundled);
$result = $resolver->install($ctx);
$this->assertSame(VendorMode::Bundled, $result->mode);
$this->assertFileExists($this->testDir.'/vendor/autoload.php');
}
public function test_install_fails_when_bundled_mode_but_no_zip(): void
{
$resolver = $this->makeResolver(canExecuteComposer: true);
$ctx = $this->makeContext(requested: VendorMode::Bundled);
$this->expectException(VendorInstallException::class);
$resolver->install($ctx);
}
}