v7.0.0-beta.2 release
This commit is contained in:
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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝 파일명에서 일반 버전을 올바르게 파싱합니다.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user