tempDirs as $dir) { if (File::isDirectory($dir)) { File::deleteDirectory($dir); } } unset($GLOBALS['__ext_full_flow_calls']); Mockery::close(); parent::tearDown(); } // ── 모듈 측 전체 흐름 ──────────────────────────────────────────────── public function test_module_full_flow_runs_applier_then_migration_then_post_run(): void { $version = '0.0.21-test.mod.flow'; $extDir = $this->makeExtensionDir('module-flow'); $stepClass = $this->writeStepFile($extDir, $version, postRunCall: 'PostRunM1'); $this->writeManifest($extDir, $version, [ ['kind' => 'permissions', 'file' => 'permissions.delta.json'], ]); $this->writeDelta($extDir, $version, 'permissions.delta.json', $this->permissionsDelta('PermApplierM1')); $this->writeApplier($extDir, $version, 'PermissionsApplier', 'PermApplierM1'); $this->writeMigration($extDir, $version, '01_FirstStep.php', 'FirstStep', 'MigrationM1A'); $this->writeMigration($extDir, $version, '02_SecondStep.php', 'SecondStep', 'MigrationM1B'); $stepInstance = new $stepClass; $module = $this->mockModule( identifier: 'vendor-module-flow', workingVersion: $version, g7Version: '>=7.0.0-beta.5', upgrades: [$version => $stepInstance], ); $this->invokeRunUpgradeSteps( manager: app(ModuleManager::class), extension: $module, from: '0.0.20', to: $version, ); $this->assertSame( ['PermApplierM1', 'MigrationM1A', 'MigrationM1B', 'PostRunM1'], $GLOBALS['__ext_full_flow_calls'], 'Applier 먼저, 다음 Migration 정렬 순서, 마지막 postRun', ); } public function test_module_data_dir_resolves_under_extension_path(): void { $version = '0.0.22-test.mod.datadir'; $extDir = $this->makeExtensionDir('module-datadir'); $stepClass = $this->writeStepFile($extDir, $version); $this->writeManifest($extDir, $version, [ ['kind' => 'permissions', 'file' => 'permissions.delta.json'], ]); $this->writeDelta($extDir, $version, 'permissions.delta.json', $this->permissionsDelta('DataDirApplier')); $this->writeApplier($extDir, $version, 'PermissionsApplier', 'DataDirApplier'); $stepInstance = new $stepClass; // dataDir() 는 protected — Reflection 으로 호출 $reflection = new \ReflectionClass($stepInstance); $dataDirMethod = $reflection->getMethod('dataDir'); $dataDirMethod->setAccessible(true); $context = new \App\Extension\UpgradeContext( fromVersion: '0.0.21', toVersion: $version, currentStep: $version, ); $resolvedPath = $this->normalizePath($dataDirMethod->invoke($stepInstance, $context)); $expected = $this->normalizePath($extDir.'/data/'.$version); $this->assertStringStartsWith( $this->normalizePath($extDir), $resolvedPath, 'dataDir() 는 확장 디렉토리 안에서 해석되어야 함 (ReflectionClass 기반 자동 분기)', ); $this->assertSame( $expected, $resolvedPath, 'dataDir() 는 확장 step 파일 옆 data/{ver}/ 를 가리켜야 함', ); } public function test_module_manifest_absent_does_not_fail(): void { // manifest.json 부재 → DataSnapshot::empty() 반환 → Applier 0개, fatal 없음 $version = '0.0.23-test.mod.empty'; $extDir = $this->makeExtensionDir('module-empty'); $stepClass = $this->writeStepFile($extDir, $version, postRunCall: 'PostRunEmpty'); // 의도적으로 data/{ver}/ 디렉토리 미생성 $stepInstance = new $stepClass; $module = $this->mockModule( identifier: 'vendor-module-empty', workingVersion: $version, g7Version: '>=7.0.0-beta.5', upgrades: [$version => $stepInstance], ); $this->invokeRunUpgradeSteps( manager: app(ModuleManager::class), extension: $module, from: '0.0.22', to: $version, ); $this->assertSame( ['PostRunEmpty'], $GLOBALS['__ext_full_flow_calls'], 'manifest 부재 시 Applier/Migration 0건, postRun 만 실행', ); } public function test_module_missing_applier_file_throws_runtime_exception(): void { $version = '0.0.24-test.mod.missing'; $extDir = $this->makeExtensionDir('module-missing'); $stepClass = $this->writeStepFile($extDir, $version); $this->writeManifest($extDir, $version, [ ['kind' => 'permissions', 'file' => 'permissions.delta.json'], ]); $this->writeDelta($extDir, $version, 'permissions.delta.json', $this->permissionsDelta('NotUsed')); // 의도적으로 appliers/PermissionsApplier.php 생성 안 함 $stepInstance = new $stepClass; $module = $this->mockModule( identifier: 'vendor-module-missing', workingVersion: $version, g7Version: '>=7.0.0-beta.5', upgrades: [$version => $stepInstance], ); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Applier file not found/'); $this->invokeRunUpgradeSteps( manager: app(ModuleManager::class), extension: $module, from: '0.0.23', to: $version, ); } // ── 플러그인 측 전체 흐름 (모듈과 1:1 대응) ───────────────────────── public function test_plugin_full_flow_runs_applier_then_migration_then_post_run(): void { $version = '0.0.31-test.plg.flow'; $extDir = $this->makeExtensionDir('plugin-flow', 'plugin'); $stepClass = $this->writeStepFile($extDir, $version, postRunCall: 'PostRunP1'); $this->writeManifest($extDir, $version, [ ['kind' => 'permissions', 'file' => 'permissions.delta.json'], ]); $this->writeDelta($extDir, $version, 'permissions.delta.json', $this->permissionsDelta('PermApplierP1')); $this->writeApplier($extDir, $version, 'PermissionsApplier', 'PermApplierP1'); $this->writeMigration($extDir, $version, '01_FirstStep.php', 'FirstStep', 'MigrationP1A'); $stepInstance = new $stepClass; $plugin = $this->mockPlugin( identifier: 'vendor-plugin-flow', workingVersion: $version, g7Version: '>=7.0.0-beta.5', upgrades: [$version => $stepInstance], ); $this->invokeRunUpgradeSteps( manager: app(PluginManager::class), extension: $plugin, from: '0.0.30', to: $version, ); $this->assertSame( ['PermApplierP1', 'MigrationP1A', 'PostRunP1'], $GLOBALS['__ext_full_flow_calls'], ); } public function test_plugin_data_dir_resolves_under_extension_path(): void { $version = '0.0.32-test.plg.datadir'; $extDir = $this->makeExtensionDir('plugin-datadir', 'plugin'); $stepClass = $this->writeStepFile($extDir, $version); $stepInstance = new $stepClass; $reflection = new \ReflectionClass($stepInstance); $dataDirMethod = $reflection->getMethod('dataDir'); $dataDirMethod->setAccessible(true); $context = new \App\Extension\UpgradeContext( fromVersion: '0.0.31', toVersion: $version, currentStep: $version, ); $resolvedPath = $this->normalizePath($dataDirMethod->invoke($stepInstance, $context)); $expected = $this->normalizePath($extDir.'/data/'.$version); $this->assertSame($expected, $resolvedPath); } /** * Windows / POSIX 경로 구분자 차이 흡수. */ private function normalizePath(string $path): string { return str_replace('\\', '/', $path); } // ── namespace 격리 회귀 가드 (KNOWN LIMITATION 해결 검증) ──────────── /** * 두 다른 번들 모듈이 *같은 step 버전 + 같은 Migration 클래스명* 을 가져도 * 각자 격리된 namespace 로 인해 PHP compile-time fatal 없이 양쪽 모두 실행됨을 검증. * * 격리 메커니즘: * - 모듈 A: `modules/_bundled/test-ext-collision-a-xxx/upgrades/data/{ver}/migrations/01_Shared.php` * → namespace `App\Upgrades\Data\Ext\Modules\TestExtCollisionA{xxx}\V{ver}\Migrations\Shared` * - 모듈 B: `modules/_bundled/test-ext-collision-b-yyy/upgrades/data/{ver}/migrations/01_Shared.php` * → namespace `App\Upgrades\Data\Ext\Modules\TestExtCollisionB{yyy}\V{ver}\Migrations\Shared` * * `require_once` 가 두 파일을 모두 로드하고, 각자 별개 FQCN 으로 선언되어 충돌 없음. * * 본 테스트는 KNOWN LIMITATION 의 해결 후 회귀 안전망 — 향후 namespace 패턴이 다시 * 변경되어 두 확장이 같은 FQCN 을 갖게 되면 본 테스트가 fatal 로 실패한다. */ public function test_two_extensions_with_same_step_version_isolated_by_namespace(): void { $sharedVersion = '0.0.42-test.shared.ver'; // 모듈 A $extDirA = $this->makeExtensionDir('collision-a'); $stepClassA = $this->writeStepFile($extDirA, $sharedVersion); $this->writeMigration($extDirA, $sharedVersion, '01_Shared.php', 'Shared', 'CollisionA'); // 모듈 B (다른 디렉토리, 다른 모듈 식별자, 같은 클래스명 + 같은 버전) $extDirB = $this->makeExtensionDir('collision-b'); $stepClassB = $this->writeStepFile($extDirB, $sharedVersion); $this->writeMigration($extDirB, $sharedVersion, '01_Shared.php', 'Shared', 'CollisionB'); $stepA = new $stepClassA; $stepB = new $stepClassB; $moduleA = $this->mockModule( identifier: 'vendor-collision-a', workingVersion: $sharedVersion, g7Version: '>=7.0.0-beta.5', upgrades: [$sharedVersion => $stepA], ); $moduleB = $this->mockModule( identifier: 'vendor-collision-b', workingVersion: $sharedVersion, g7Version: '>=7.0.0-beta.5', upgrades: [$sharedVersion => $stepB], ); $manager = app(ModuleManager::class); $this->invokeRunUpgradeSteps( manager: $manager, extension: $moduleA, from: '0.0.41', to: $sharedVersion, ); $this->invokeRunUpgradeSteps( manager: $manager, extension: $moduleB, from: '0.0.41', to: $sharedVersion, ); // 두 확장 모두 자기 코드의 라벨로 호출되어야 함 — namespace 격리 성립 $this->assertSame( ['CollisionA', 'CollisionB'], $GLOBALS['__ext_full_flow_calls'], '두 확장이 같은 step 버전 + 같은 클래스명을 가져도 namespace 격리로 각자 실행됨', ); } public function test_versionedNamespace_uses_distinct_namespaces_for_different_extensions_same_version(): void { // namespace 격리의 *근원* 정적 검증 — DataSnapshot 단위에서 검증. $context = new \App\Extension\UpgradeContext( fromVersion: '0.0.0', toVersion: '1.0.0', currentStep: '1.0.0', ); $nsModuleA = \App\Extension\Upgrade\DataSnapshot::versionedNamespace( $context, '/var/www/modules/_bundled/vendor-foo/upgrades/data/1.0.0', ); $nsModuleB = \App\Extension\Upgrade\DataSnapshot::versionedNamespace( $context, '/var/www/modules/_bundled/vendor-bar/upgrades/data/1.0.0', ); $nsPlugin = \App\Extension\Upgrade\DataSnapshot::versionedNamespace( $context, '/var/www/plugins/_bundled/vendor-baz/upgrades/data/1.0.0', ); $nsCore = \App\Extension\Upgrade\DataSnapshot::versionedNamespace( $context, '/var/www/upgrades/data/1.0.0', ); $this->assertNotSame($nsModuleA, $nsModuleB); $this->assertNotSame($nsModuleA, $nsPlugin); $this->assertNotSame($nsModuleA, $nsCore); $this->assertNotSame($nsModuleB, $nsCore); $this->assertNotSame($nsPlugin, $nsCore); // 확장 식별자 segment 가 실제로 포함되어 있는지 확인 $this->assertStringContainsString('VendorFoo', $nsModuleA); $this->assertStringContainsString('VendorBar', $nsModuleB); $this->assertStringContainsString('VendorBaz', $nsPlugin); // 코어는 확장 segment 없음 $this->assertStringNotContainsString('Ext\\Modules', $nsCore); $this->assertStringNotContainsString('Ext\\Plugins', $nsCore); } // ── 헬퍼: 디렉토리/파일 fixture ───────────────────────────────────── /** * 확장 디렉토리 fixture 작성. 경로에 `modules/_bundled/{id}/upgrades/` 또는 * `plugins/_bundled/{id}/upgrades/` 마커가 포함되어야 `DataSnapshot::versionedNamespace` * 가 확장 namespace 분기로 진입한다. * * type 이 'module' 이면 modules/_bundled, 'plugin' 이면 plugins/_bundled. */ private function makeExtensionDir(string $label, string $type = 'module'): string { $extType = $type === 'plugin' ? 'plugins' : 'modules'; $identifier = 'test-ext-'.$label.'-'.bin2hex(random_bytes(4)); // storage/.../testing/ 안의 합성 경로 — 마커 substring 만 매칭하면 namespace 분기 작동. $base = storage_path('framework/testing/'.$extType.'/_bundled/'.$identifier.'/upgrades'); File::ensureDirectoryExists($base); // 정리 대상은 {id} 디렉토리 (storage/framework/testing/{type}/_bundled/{id}) $this->tempDirs[] = dirname($base); return $base; } /** * 확장의 step 파일을 작성 후 require_once + FQCN 반환. * * step 클래스명은 fixture 별 고유 — PHP 클래스 재선언 회피. */ private function writeStepFile(string $extDir, string $version, ?string $postRunCall = null): string { $unique = 'ExtStep_'.bin2hex(random_bytes(8)); $stepFile = $extDir.'/'.$unique.'.php'; $postRunBody = $postRunCall !== null ? "\$GLOBALS['__ext_full_flow_calls'][] = '{$postRunCall}';" : ''; $contents = << $version, 'files' => $entries], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE, ), ); } private function writeDelta(string $extDir, string $version, string $file, array $delta): void { $dir = $extDir.'/data/'.$version; File::ensureDirectoryExists($dir); File::put($dir.'/'.$file, json_encode($delta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } private function writeApplier(string $extDir, string $version, string $className, string $callLabel): void { $dir = $extDir.'/data/'.$version.'/appliers'; File::ensureDirectoryExists($dir); $namespace = $this->namespaceForDataFile($dir, $version); $contents = <<namespaceForDataFile($dir, $version); $contents = << [], 'removed' => [], 'renamed' => [], ]; } // ── 헬퍼: Manager 진입점 ───────────────────────────────────────────── private function mockModule( string $identifier, string $workingVersion, ?string $g7Version, array $upgrades, ): ModuleInterface { $module = Mockery::mock(ModuleInterface::class); $module->shouldReceive('getIdentifier')->andReturn($identifier); $module->shouldReceive('getVersion')->andReturn($workingVersion); $module->shouldReceive('getRequiredCoreVersion')->andReturn($g7Version); $module->shouldReceive('upgrades')->andReturn($upgrades); return $module; } private function mockPlugin( string $identifier, string $workingVersion, ?string $g7Version, array $upgrades, ): PluginInterface { $plugin = Mockery::mock(PluginInterface::class); $plugin->shouldReceive('getIdentifier')->andReturn($identifier); $plugin->shouldReceive('getVersion')->andReturn($workingVersion); $plugin->shouldReceive('getRequiredCoreVersion')->andReturn($g7Version); $plugin->shouldReceive('upgrades')->andReturn($upgrades); return $plugin; } /** * @param ModuleManager|PluginManager $manager * @param ModuleInterface|PluginInterface $extension */ private function invokeRunUpgradeSteps( $manager, $extension, string $from, string $to, ?\Closure $onStep = null, ): void { $reflection = new \ReflectionClass($manager); $method = $reflection->getMethod('runUpgradeSteps'); $method->setAccessible(true); $method->invoke($manager, $extension, $from, $to, false, $onStep); } }