Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를 --no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는 `composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를 찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상 종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다. (sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10) 3계층으로 막는다. 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다. 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다. 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없다). 업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고 CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의 복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다. 실측 중 드러난 결함 2건을 함께 고쳤다. - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다. - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다). 인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인 카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를 정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다. 코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3). 코어 7.0.10 → 7.0.11.
585 lines
27 KiB
PHP
585 lines
27 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Console\Commands;
|
|
|
|
use App\Console\Commands\Core\CoreUpdateCommand;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Services\CoreUpdateService;
|
|
use App\Services\LanguagePackService;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\File;
|
|
use Mockery;
|
|
use Mockery\MockInterface;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `core:execute-upgrade-steps` 단독 실행 시 사전·사후 단계 자동 수행 계약 테스트.
|
|
*
|
|
* 부모 `CoreUpdateCommand` 가 spawn 으로 호출할 때는 `--skip-migrations` /
|
|
* `--skip-resync` / `--skip-version-env` / `--skip-cache-clear` /
|
|
* `--skip-bundled-updates` 5개 옵션을 전달하여 중복을 회피한다. 운영자가 단독으로
|
|
* 호출(HANDOFF 안내문 또는 수동 복구 목적) 하는 경우엔 옵션 미전달 → 기본값으로
|
|
* 5단계가 자동 수행되어야 공개 이슈 gnuboard/g7#34 의 수동 절차가 단일 명령으로 통합된다.
|
|
*
|
|
* 본 계약은 CoreUpdateService + 3개 Manager + LanguagePackService 를 Mockery 로
|
|
* swap 하여 호출 여부만 검증한다. 실제 마이그레이션·시더 부작용은 테스트 범위 밖.
|
|
*/
|
|
class ExecuteUpgradeStepsStandaloneTest extends TestCase
|
|
{
|
|
private array $createdPaths = [];
|
|
|
|
/**
|
|
* 테스트 진입 전의 spawn 플래그 상태 (없으면 false) — tearDown 에서 되돌린다.
|
|
*/
|
|
private string|false $originalSpawnFlag = false;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 이 클래스의 절반은 "spawn 플래그가 없는 단독 실행" 을 전제한다. 그런데 같은 프로세스에서
|
|
// 앞서 돈 테스트가 `core:update`(CoreUpdateCommand::handle) 를 한 번이라도 거치면 그 플래그가
|
|
// 프로세스 env 에 남아 여기 도착한다 — 부모 커맨드는 "프로세스 종료와 함께 소멸" 을 전제로
|
|
// 정리하지 않기 때문이다. 실행 순서에 따라 결과가 갈리지 않도록 진입 시 비우고 종료 시 되돌린다.
|
|
$this->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, "<?php return [];\n");
|
|
|
|
$originalCacheEnv = $_ENV['APP_CONFIG_CACHE'] ?? null;
|
|
$_ENV['APP_CONFIG_CACHE'] = $relativeCachePath;
|
|
$_SERVER['APP_CONFIG_CACHE'] = $relativeCachePath;
|
|
putenv('APP_CONFIG_CACHE='.$relativeCachePath);
|
|
$originalEnv = getenv('G7_UPDATE_IN_PROGRESS');
|
|
putenv('G7_UPDATE_IN_PROGRESS=1');
|
|
|
|
try {
|
|
$this->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<string, mixed> $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 = <<<PHP
|
|
<?php
|
|
|
|
namespace App\\Upgrades;
|
|
|
|
use App\\Contracts\\Extension\\UpgradeStepInterface;
|
|
use App\\Extension\\UpgradeContext;
|
|
|
|
class {$className} implements UpgradeStepInterface
|
|
{
|
|
public function run(UpgradeContext \$context): void
|
|
{
|
|
// noop — 본 테스트는 사전/사후 단계 호출만 검증.
|
|
}
|
|
}
|
|
PHP;
|
|
|
|
File::put($path, $code);
|
|
$this->createdPaths[] = $path;
|
|
}
|
|
}
|