originalSpawnFlag = getenv('G7_UPDATE_IN_PROGRESS'); putenv('G7_UPDATE_IN_PROGRESS'); unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']); // 빈 upgrade step 디렉토리 보장 — 본 테스트는 step 자체가 아닌 사전/사후 단계만 검증. // 임시 dummy step 파일을 작성해 from < to 비교가 ">=" 조건을 통과하도록 한다. $this->writeNoopStep('0.9.1', 'standalone_noop'); } protected function tearDown(): void { foreach ($this->createdPaths as $path) { if (File::exists($path)) { File::delete($path); } } $this->createdPaths = []; Mockery::close(); if ($this->originalSpawnFlag === false) { putenv('G7_UPDATE_IN_PROGRESS'); unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']); } else { putenv('G7_UPDATE_IN_PROGRESS='.$this->originalSpawnFlag); $_ENV['G7_UPDATE_IN_PROGRESS'] = $this->originalSpawnFlag; $_SERVER['G7_UPDATE_IN_PROGRESS'] = $this->originalSpawnFlag; } parent::tearDown(); } public function test_standalone_invocation_runs_all_five_pre_and_post_steps(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once()->with('0.9.1'); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $exitCode = $this->runCommand([]); $this->assertSame(0, $exitCode); } public function test_skip_migrations_option_bypasses_migrations(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldNotReceive('runMigrations'); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $exitCode = $this->runCommand(['--skip-migrations' => true]); $this->assertSame(0, $exitCode); } public function test_skip_resync_option_bypasses_resync(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $exitCode = $this->runCommand(['--skip-resync' => true]); $this->assertSame(0, $exitCode); } public function test_skip_version_env_option_bypasses_version_env_update(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $exitCode = $this->runCommand(['--skip-version-env' => true]); $this->assertSame(0, $exitCode); } public function test_skip_cache_clear_option_bypasses_cache_clear(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldNotReceive('clearAllCaches'); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $exitCode = $this->runCommand(['--skip-cache-clear' => true]); $this->assertSame(0, $exitCode); } /** * 캐시 정리 블록과 함께 상주 큐 워커 재시작 신호가 나간다 (7.0.11). * * 큐 워커는 부팅이 한 번뿐이라 코어 코드·config 를 기동 시점 상태로 물고 있다. 업데이트가 * 파일을 전부 교체해도 워커는 옛 코드로 잡을 계속 처리하며 그 사실이 오류로 드러나지 않는다. * * @effects core_update_step11_signals_queue_restart */ public function test_standalone_post_step_signals_queue_restart(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('signalQueueRestart')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $this->assertSame(0, $this->runCommand([])); } /** * 캐시 정리를 건너뛰는 경로(spawn 자식)에서는 신호도 보내지 않는다 — 부모가 보낸다. * * @effects core_update_step11_signals_queue_restart */ public function test_skip_cache_clear_also_skips_queue_restart_signal(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('signalQueueRestart'); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); $this->assertSame(0, $this->runCommand(['--skip-cache-clear' => true])); } public function test_skip_bundled_updates_option_bypasses_bundled_prompt(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldReceive('clearAllCaches')->once(); // 번들 업데이트 prompt 자체가 호출되지 않으므로 collectBundled* 도 호출 없음. $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $exitCode = $this->runCommand(['--skip-bundled-updates' => true]); $this->assertSame(0, $exitCode); } public function test_all_skip_options_bypass_all_pre_and_post_steps(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); // CoreUpdateCommand spawn 호출 시나리오 등가 — runUpgradeSteps 만 호출. $service->shouldNotReceive('runMigrations'); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $exitCode = $this->runCommand([ '--skip-migrations' => true, '--skip-resync' => true, '--skip-version-env' => true, '--skip-cache-clear' => true, '--skip-bundled-updates' => true, ]); $this->assertSame(0, $exitCode); } /** * 부모(구버전 코드)의 정리 단계가 남긴 빈 격리 디렉토리(`core_{ts}/extracted` 껍데기)는 * 신버전 코드로 도는 이 자식이 치운다. spawn 자식 모드에서도, 단독 실행에서도 호출된다 — * 구버전 부모에서 올라오는 업데이트(7.0.9→7.0.10)를 덮는 유일한 자리이기 때문이다. * * @effects ExecuteUpgradeStepsCommand_sweeps_empty_staging_directories_left_by_parent */ public function test_스텝_완료_후_빈_격리_디렉토리를_청소한다(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('sweepEmptyStagingDirectories')->once()->andReturn(1); putenv('G7_UPDATE_IN_PROGRESS=1'); $_ENV['G7_UPDATE_IN_PROGRESS'] = '1'; $_SERVER['G7_UPDATE_IN_PROGRESS'] = '1'; try { $exitCode = $this->runCommand([]); } finally { putenv('G7_UPDATE_IN_PROGRESS'); unset($_ENV['G7_UPDATE_IN_PROGRESS'], $_SERVER['G7_UPDATE_IN_PROGRESS']); } $this->assertSame(0, $exitCode); } /** * 자식이 이전 버전 config 캐시로 부팅했으면(구버전 부모는 spawn 전에 캐시를 비우지 않는다) * `config('app.update.restore_ownership_group_writable')` 은 옛 목록이다. 이때는 디스크의 * `config/app.php` 를 직접 읽어 신버전이 추가한 디렉토리까지 권한 정상화 대상에 넣는다 * (7.0.9→7.0.10 서버 실측: `public/build/ext` 누락). * * @effects execute_upgrade_steps_child_reads_update_config_from_disk_when_config_is_cached */ public function test_config_캐시로_부팅한_자식은_쓰기권한_목록을_디스크_config_에서_읽는다(): void { [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); config(['app.update.restore_ownership_group_writable' => ['stale/only']]); $service->shouldReceive('freshDiskUpdateConfig') ->once() ->with('restore_ownership_group_writable', []) ->andReturn(['fresh/dir']); $service->shouldReceive('ensureWritableDirectories') ->once() ->withArgs(fn (array $paths) => $paths === ['fresh/dir']); $service->shouldReceive('runUpgradeSteps')->once(); // 상대 경로 — Application::normalizeCachePath 는 `/`·`\` 로 시작하지 않는 값을 basePath 기준으로 // 해석하므로 Windows 절대 경로는 어긋난다. $relativeCachePath = 'storage/framework/testing/child-config-cache-'.uniqid().'.php'; $cachePath = base_path($relativeCachePath); File::ensureDirectoryExists(dirname($cachePath)); File::put($cachePath, "assertSame($cachePath, $this->app->getCachedConfigPath(), '전제: APP_CONFIG_CACHE 가 캐시 경로를 정한다'); $this->assertFileExists($cachePath, '전제: 자식 시점에 config 캐시 파일이 존재한다'); $this->assertSame(0, $this->runCommand([])); } finally { if ($originalEnv === false) { putenv('G7_UPDATE_IN_PROGRESS'); } else { putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); } if ($originalCacheEnv === null) { unset($_ENV['APP_CONFIG_CACHE'], $_SERVER['APP_CONFIG_CACHE']); putenv('APP_CONFIG_CACHE'); } else { $_ENV['APP_CONFIG_CACHE'] = $originalCacheEnv; $_SERVER['APP_CONFIG_CACHE'] = $originalCacheEnv; putenv('APP_CONFIG_CACHE='.$originalCacheEnv); } File::delete($cachePath); } } public function test_spawn_child_env_bypasses_all_pre_and_post_steps_without_skip_options(): void { // 구버전 부모(beta.5) 가 신버전 자식(beta.6+) 을 spawn 할 때 `--skip-*` 옵션을 모르므로 // 전달하지 않지만, `G7_UPDATE_IN_PROGRESS=1` env 는 beta.3+ 부모부터 항상 설정한다. // 자식은 env 시그널만으로 본인이 spawn 컨텍스트임을 감지하여 5단계 모두 자동 스킵해야 // 한다 (옵션 부재 + non-interactive 환경에서 prompt 호출로 인한 Aborted exit=1 회귀 차단). [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldNotReceive('runMigrations'); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); putenv('G7_UPDATE_IN_PROGRESS=1'); try { $exitCode = $this->runCommand([]); $this->assertSame(0, $exitCode); } finally { if ($originalEnv === false) { putenv('G7_UPDATE_IN_PROGRESS'); } else { putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); } } } public function test_spawn_child_env_skips_even_when_force_is_set(): void { // env=1 + --force 동시 적용 시에도 사후 단계 모두 스킵. force 는 자식이 실행하는 step // 자체의 동일 버전 강제 플래그로만 사용되며, env 시그널이 우선한다 (부모가 force 든 // 아니든 부모가 5단계를 책임진다는 계약 보존). [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldNotReceive('runMigrations'); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); putenv('G7_UPDATE_IN_PROGRESS=1'); try { $exitCode = $this->runCommand(['--force' => true]); $this->assertSame(0, $exitCode); } finally { if ($originalEnv === false) { putenv('G7_UPDATE_IN_PROGRESS'); } else { putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); } } } public function test_standalone_invocation_without_env_still_runs_all_steps(): void { // env 미설정 (운영자 수동 호출) 시 기존 단독 실행 계약 보존 — 5단계 모두 실행. // env 가드 도입이 단독 실행 경로에 회귀를 일으키지 않음을 확인. [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldReceive('runMigrations')->once(); $service->shouldReceive('reloadCoreConfigAndResync')->once(); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldReceive('updateVersionInEnv')->once(); $service->shouldReceive('clearAllCaches')->once(); $service->shouldReceive('collectBundledExtensionUpdates')->once()->andReturn([ 'modules' => [], 'plugins' => [], 'templates' => [], ]); $langPack->shouldReceive('collectBundledLangPackUpdates')->once()->andReturn([]); // env 가 설정되지 않은 상태임을 명시적으로 보장. $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); putenv('G7_UPDATE_IN_PROGRESS'); try { $exitCode = $this->runCommand([]); $this->assertSame(0, $exitCode); } finally { if ($originalEnv !== false) { putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); } } } public function test_steps_only_option_runs_only_upgrade_steps(): void { // --steps-only: 업그레이드 스텝만 실행. 권한 정상화·마이그레이션·resync· // 버전 갱신·캐시 정리·번들 업데이트 등 모든 보조 단계를 생략한다. [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldNotReceive('runMigrations'); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldNotReceive('ensureWritableDirectories'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $exitCode = $this->runCommand(['--steps-only' => true]); $this->assertSame(0, $exitCode); } public function test_steps_only_skips_aux_steps_even_without_env_signal(): void { // env 미설정(운영자 단독 호출) + --steps-only → 단독 실행 시 자동 수행되던 // 5단계도 모두 생략. --steps-only 가 단독 실행 계약보다 우선한다. [$service, $module, $plugin, $template, $langPack] = $this->bindMocks(); $service->shouldNotReceive('runMigrations'); $service->shouldNotReceive('reloadCoreConfigAndResync'); $service->shouldReceive('runUpgradeSteps')->once(); $service->shouldNotReceive('updateVersionInEnv'); $service->shouldNotReceive('clearAllCaches'); $service->shouldNotReceive('collectBundledExtensionUpdates'); $langPack->shouldNotReceive('collectBundledLangPackUpdates'); $originalEnv = getenv('G7_UPDATE_IN_PROGRESS'); putenv('G7_UPDATE_IN_PROGRESS'); try { $exitCode = $this->runCommand(['--steps-only' => true]); $this->assertSame(0, $exitCode); } finally { if ($originalEnv !== false) { putenv('G7_UPDATE_IN_PROGRESS='.$originalEnv); } } } public function test_spawn_passes_all_five_skip_options_to_child(): void { // 부모 CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식 command 배열에 // 5개 `--skip-*` 옵션을 추가하는지 검증 (escapeshellarg 후 commandLine 문자열에 포함). $reflection = new \ReflectionClass(CoreUpdateCommand::class); $source = File::get($reflection->getFileName()); $this->assertStringContainsString("\$command[] = '--skip-migrations';", $source); $this->assertStringContainsString("\$command[] = '--skip-resync';", $source); $this->assertStringContainsString("\$command[] = '--skip-version-env';", $source); $this->assertStringContainsString("\$command[] = '--skip-cache-clear';", $source); $this->assertStringContainsString("\$command[] = '--skip-bundled-updates';", $source); } /** * CoreUpdateService + 3개 Manager + LanguagePackService 를 컨테이너에 mock 으로 swap. * * @return array{0: MockInterface, 1: MockInterface, 2: MockInterface, 3: MockInterface, 4: MockInterface} */ private function bindMocks(): array { $service = Mockery::mock(CoreUpdateService::class); // 종료부의 root 산출물 소유권 정상화(#615 도입)는 모든 경로에서 호출되며 본 테스트의 관심사가 // 아니다 — 기본 허용으로 두어 각 케이스가 자기 단계만 단언하게 한다. $service->shouldReceive('normalizeRuntimeOwnershipAfterRootRun')->andReturnNull()->byDefault(); // 종료부의 빈 격리 디렉토리 청소(7.0.10 도입)도 모든 경로에서 호출된다 — 전용 케이스가 // once() 로 단언하고, 나머지 케이스는 기본 허용. $service->shouldReceive('sweepEmptyStagingDirectories')->andReturn(0)->byDefault(); // 캐시 정리 블록이 도는 경로에서는 상주 큐 워커 재시작 신호도 함께 나간다 (7.0.11 도입). // 전용 케이스가 once()/never() 로 단언하고, 나머지 케이스는 기본 허용. $service->shouldReceive('signalQueueRestart')->andReturnNull()->byDefault(); $module = Mockery::mock(ModuleManager::class); $plugin = Mockery::mock(PluginManager::class); $template = Mockery::mock(TemplateManager::class); $langPack = Mockery::mock(LanguagePackService::class); // 콘솔 커맨드 resolve 시 Service 계층 생성자가 매니저의 스캔 메서드를 // 호출할 수 있다 (예: TemplateService 가 TemplateManagerInterface::loadTemplates() // 를 생성자에서 호출). 스캔 메서드는 부수효과만 있으므로 무해하게 허용한다. $module->shouldReceive('loadModules')->andReturnNull()->byDefault(); $plugin->shouldReceive('loadPlugins')->andReturnNull()->byDefault(); $template->shouldReceive('loadTemplates')->andReturnNull()->byDefault(); $template->shouldReceive('ensureLoaded')->andReturnNull()->byDefault(); $this->app->instance(CoreUpdateService::class, $service); $this->app->instance(ModuleManager::class, $module); $this->app->instance(PluginManager::class, $plugin); $this->app->instance(TemplateManager::class, $template); $this->app->instance(LanguagePackService::class, $langPack); return [$service, $module, $plugin, $template, $langPack]; } /** * 공통 옵션을 적용해 `core:execute-upgrade-steps` 를 호출. * * @param array $extra --skip-* 등 추가 옵션 */ private function runCommand(array $extra): int { $params = array_merge([ '--from' => '0.9.0', '--to' => '0.9.1', '--force' => true, ], $extra); ob_start(); $exitCode = Artisan::call('core:execute-upgrade-steps', $params); ob_end_clean(); return $exitCode; } /** * 본 테스트가 from < to 범위 안에 들도록 더미 upgrade step 파일을 작성. * 핸들러 자체는 아무것도 하지 않는다 — 사전/사후 단계 호출만 검증. */ private function writeNoopStep(string $version, string $suffix): void { $versionSnake = str_replace('.', '_', $version); $className = "Upgrade_{$versionSnake}_test_{$suffix}"; $path = base_path("upgrades/{$className}.php"); $code = <<createdPaths[] = $path; } }