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.
118 lines
5.1 KiB
PHP
118 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services;
|
|
|
|
use App\Services\CoreUpdateService;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 코어 업데이트 후 큐 워커 재시작 신호 회귀 테스트 (dev-g7 #658).
|
|
*
|
|
* 코어 업데이트는 어느 지점에서도 `queue:restart` 를 보내지 않았다. 큐 워커는 부팅이 한 번뿐이라
|
|
* 코어 코드·config·확장 목록을 기동 시점 상태로 물고 있고, 업데이트가 파일을 전부 교체해도
|
|
* 옛 코드로 잡을 계속 처리한다 — 오류로 드러나지 않으므로 운영자가 손수 재시작할 때까지
|
|
* 조용히 어긋난 채 돈다(번들 확장 일괄 업데이트는 Manager 직접 호출이라 기존
|
|
* `ExtensionUpdateQueueListener` 도 걸리지 않는다).
|
|
*/
|
|
class CoreUpdateServiceQueueRestartTest extends TestCase
|
|
{
|
|
/**
|
|
* `queue:restart` 를 정확히 한 번 호출한다.
|
|
*
|
|
* @effects core_update_step11_signals_queue_restart
|
|
*/
|
|
#[Test]
|
|
public function signal_queue_restart_는_queue_restart_를_한_번_호출한다(): void
|
|
{
|
|
Artisan::shouldReceive('call')->once()->with('queue:restart')->andReturn(0);
|
|
|
|
app(CoreUpdateService::class)->signalQueueRestart();
|
|
|
|
$this->assertTrue(true, 'Artisan 기대치는 tearDown 에서 검증된다');
|
|
}
|
|
|
|
/**
|
|
* 신호 전송 실패는 업데이트를 되돌리지 않는다 — 예외를 삼키고 계속한다.
|
|
*/
|
|
#[Test]
|
|
public function signal_queue_restart_는_실패해도_예외를_전파하지_않는다(): void
|
|
{
|
|
Artisan::shouldReceive('call')
|
|
->once()
|
|
->with('queue:restart')
|
|
->andThrow(new \RuntimeException('큐 재시작 신호 전송 실패 시뮬레이션'));
|
|
|
|
app(CoreUpdateService::class)->signalQueueRestart();
|
|
|
|
$this->assertTrue(true, '예외가 전파되면 이 지점에 도달하지 못한다');
|
|
}
|
|
|
|
/**
|
|
* 업데이트를 마치는 세 지점이 캐시 정리 **직후** 신호를 보낸다 (호출 지점 계약).
|
|
*
|
|
* 행위 테스트로는 이 축을 덮을 수 없다 — `CoreUpdateCommandHandoffTest` 의 docblock 이
|
|
* 밝히듯 `handle()` 의 catch cleanup 시퀀스는 GitHub 연동·migration·vendor 설치까지
|
|
* 끌고 들어와야 하는 통합 면적이라 그 테스트 범위 밖이다. 그래서 세 지점의 배치를
|
|
* 소스로 잠근다: 새 마무리 지점이 신호 없이 추가되면 여기서 걸린다.
|
|
*
|
|
* 롤백 catch 는 의도적 제외다 — 백업으로 되돌린 **옛** 코드가 다시 도는 자리라 워커를
|
|
* 재기동시킬 이유가 없다.
|
|
*
|
|
* @effects core_update_step11_signals_queue_restart
|
|
*/
|
|
#[Test]
|
|
public function 캐시_정리_뒤_큐_신호를_보내는_지점이_셋이고_롤백은_제외된다(): void
|
|
{
|
|
$sites = [];
|
|
|
|
foreach ([
|
|
'app/Console/Commands/Core/CoreUpdateCommand.php',
|
|
'app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php',
|
|
] as $relative) {
|
|
$source = (string) file_get_contents(base_path($relative));
|
|
|
|
$offset = 0;
|
|
while (($pos = strpos($source, '$service->clearAllCaches();', $offset)) !== false) {
|
|
// 정리 직후 구간(다음 400자)에 신호가 있는지 본다 — 사이에 낀 주석은 허용하되
|
|
// 다른 마무리 단계를 한참 지나 붙는 것은 "직후" 가 아니다.
|
|
$window = substr($source, $pos, 400);
|
|
$sites[] = [
|
|
'file' => $relative,
|
|
'line' => substr_count(substr($source, 0, $pos), "\n") + 1,
|
|
'signals' => str_contains($window, '$service->signalQueueRestart();'),
|
|
// 롤백 지점은 백업 복원 직후라 바로 뒤에 "롤백 후 캐시 정리 완료" 로그가 온다.
|
|
'is_rollback' => str_contains($window, '롤백 후 캐시 정리'),
|
|
];
|
|
$offset = $pos + 1;
|
|
}
|
|
}
|
|
|
|
$this->assertNotEmpty($sites, '전제: clearAllCaches 호출 지점을 찾을 수 있어야 한다');
|
|
|
|
$signalling = array_values(array_filter($sites, fn (array $s): bool => $s['signals']));
|
|
$silent = array_values(array_filter($sites, fn (array $s): bool => ! $s['signals']));
|
|
|
|
$this->assertCount(
|
|
3,
|
|
$signalling,
|
|
'Step 11 · 핸드오프 cleanup · 단독 실행 사후 단계 셋이 신호를 보내야 한다. 실제: '
|
|
.json_encode($sites, JSON_UNESCAPED_SLASHES)
|
|
);
|
|
|
|
$this->assertCount(
|
|
1,
|
|
$silent,
|
|
'신호를 보내지 않는 지점은 롤백 catch 하나뿐이어야 한다. 실제: '
|
|
.json_encode($silent, JSON_UNESCAPED_SLASHES)
|
|
);
|
|
|
|
$this->assertTrue(
|
|
$silent[0]['is_rollback'],
|
|
'제외된 지점은 롤백 catch 여야 한다 (옛 코드로 되돌린 자리라 워커 재기동 대상이 아니다). 실제: '
|
|
.json_encode($silent[0], JSON_UNESCAPED_SLASHES)
|
|
);
|
|
}
|
|
}
|