Files
Gnuboard7/tests/Feature/Console/Commands/ExecuteUpgradeStepsStandaloneTest.php
T
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

418 lines
18 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 = [];
protected function setUp(): void
{
parent::setUp();
// 빈 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();
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);
}
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);
}
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);
$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;
}
}