Files
Gnuboard7/tests/Unit/Services/CoreUpdateServiceTest.php
T
HeuJung 487283b33a fix(core): core:update 증분 모드가 _bundled 확장 파일을 반영하도록 protected 오버라이드
targets 에 명시된 {modules,plugins,templates,lang-packs}/_bundled 가
상위 protected(modules 등)에 걸려 3-way apply 목록에서 제외되던 결함 수정.
targets 가 protected 보다 더 구체적이면 오버라이드해 통과시켜, 코어 업데이트로
번들 확장 갱신 파일(composer.json·vendor-bundle.json 등)이 정상 반영되도록 한다.
자동 발견 폴백의 방어 및 prune 롤백 경로는 불변.

아울러 이슈 템플릿의 사용법 문의 링크를 Discussions 에서 sir.kr Q&A 로 전환.
2026-07-08 13:54:18 +09:00

1766 lines
74 KiB
PHP

<?php
namespace Tests\Unit\Services;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Services\CoreUpdateService;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* CoreUpdateService 단위 테스트
*
* 코어 업데이트 서비스의 주요 메서드를 검증합니다:
* - 업데이트 확인 (GitHub API)
* - CHANGELOG.md 파싱
* - _pending 디렉토리 검증
* - 유지보수 모드 전환
* - .env 버전 갱신
* - 실패 리포트 생성
* - _pending 정리
*/
class CoreUpdateServiceTest extends TestCase
{
private CoreUpdateService $service;
/**
* 테스트에서 사용하는 임시 디렉토리 목록 (tearDown에서 정리)
*
* @var array<string>
*/
private array $tempDirs = [];
/**
* 테스트에서 사용하는 임시 파일 목록 (tearDown에서 정리)
*
* @var array<string>
*/
private array $tempFiles = [];
protected function setUp(): void
{
parent::setUp();
$this->service = new CoreUpdateService;
}
protected function tearDown(): void
{
// 임시 파일 정리
foreach ($this->tempFiles as $file) {
if (File::exists($file)) {
File::delete($file);
}
}
// 임시 디렉토리 정리
foreach ($this->tempDirs as $dir) {
if (File::isDirectory($dir)) {
File::deleteDirectory($dir);
}
}
parent::tearDown();
}
// ========================================================================
// checkForUpdates() - GitHub API를 통한 업데이트 확인
// ========================================================================
/**
* checkForUpdates()가 올바른 구조의 배열을 반환하는지 검증합니다.
*/
public function test_check_for_updates_returns_correct_structure(): void
{
config(['app.update.github_url' => 'https://github.com/test-owner/test-repo']);
$service = new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => '2.0.0', 'error' => null];
}
};
$result = $service->checkForUpdates();
// 반환 구조 검증
$this->assertIsArray($result);
$this->assertArrayHasKey('update_available', $result);
$this->assertArrayHasKey('current_version', $result);
$this->assertArrayHasKey('latest_version', $result);
$this->assertArrayHasKey('github_url', $result);
// 타입 검증
$this->assertIsBool($result['update_available']);
$this->assertIsString($result['current_version']);
$this->assertIsString($result['latest_version']);
// 최신 버전이 현재보다 높으면 업데이트 가능
$this->assertEquals('2.0.0', $result['latest_version']);
$this->assertArrayNotHasKey('check_failed', $result);
}
/**
* GitHub API 실패 시 check_failed를 포함한 에러 응답을 반환하는지 검증합니다.
*/
public function test_check_for_updates_returns_check_failed_when_github_fails(): void
{
config(['app.update.github_url' => 'https://github.com/test-owner/test-repo']);
$service = new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => null, 'error' => '프라이빗 저장소입니다.'];
}
};
$result = $service->checkForUpdates();
$this->assertFalse($result['update_available']);
$this->assertEquals($result['current_version'], $result['latest_version']);
$this->assertTrue($result['check_failed']);
$this->assertEquals('프라이빗 저장소입니다.', $result['error']);
}
/**
* GitHub API 성공 + 릴리스 없음 (version null, error null)일 때 정상 처리되는지 검증합니다.
*/
public function test_check_for_updates_handles_no_releases(): void
{
config(['app.update.github_url' => 'https://github.com/test-owner/test-repo']);
$service = new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => null, 'error' => '릴리스가 없습니다.'];
}
};
$result = $service->checkForUpdates();
$this->assertFalse($result['update_available']);
$this->assertTrue($result['check_failed']);
}
// ========================================================================
// getChangelog() - CHANGELOG.md 파싱
// ========================================================================
// 참고: GitHub API 헤더/상태코드/URL 해석/다운로드 로직은 `GithubHelper`로 이관되어
// `GithubHelperTest`에서 Http::fake() 기반으로 검증합니다. CoreUpdateService에 있던
// buildGithubHeaders / extractHttpStatusCode / resolveGithubArchiveUrl / downloadArchive
// 중복 구현은 제거되었습니다. (`allow_url_fopen=Off` 대응)
/**
* CHANGELOG.md 파일을 파싱하여 올바른 버전 엔트리를 반환하는지 검증합니다.
*/
public function test_get_changelog_parses_file(): void
{
$changelogContent = <<<'MD'
# Changelog
## [1.2.0] - 2026-03-01
### Added
- 새로운 기능 A
- 새로운 기능 B
### Fixed
- 버그 수정 C
## [1.1.0] - 2026-02-15
### Changed
- 변경 사항 D
MD;
$changelogPath = base_path('CHANGELOG.md');
$originalExists = File::exists($changelogPath);
$originalContent = $originalExists ? File::get($changelogPath) : null;
File::put($changelogPath, $changelogContent);
$this->tempFiles[] = $changelogPath;
try {
$result = $this->service->getChangelog();
$this->assertIsArray($result);
$this->assertCount(2, $result);
// 첫 번째 엔트리 검증
$this->assertEquals('1.2.0', $result[0]['version']);
$this->assertEquals('2026-03-01', $result[0]['date']);
$this->assertCount(2, $result[0]['categories']);
// Added 카테고리 검증
$addedCategory = $result[0]['categories'][0];
$this->assertEquals('Added', $addedCategory['name']);
$this->assertCount(2, $addedCategory['items']);
$this->assertContains('새로운 기능 A', $addedCategory['items']);
$this->assertContains('새로운 기능 B', $addedCategory['items']);
// Fixed 카테고리 검증
$fixedCategory = $result[0]['categories'][1];
$this->assertEquals('Fixed', $fixedCategory['name']);
$this->assertCount(1, $fixedCategory['items']);
// 두 번째 엔트리 검증
$this->assertEquals('1.1.0', $result[1]['version']);
} finally {
// 원본 파일 복원
if ($originalContent !== null) {
File::put($changelogPath, $originalContent);
}
// tempFiles 에서 제거 (원본 복원 완료)
$this->tempFiles = array_filter($this->tempFiles, fn ($f) => $f !== $changelogPath);
}
}
/**
* 버전 범위를 지정하여 CHANGELOG를 필터링하는지 검증합니다.
*/
public function test_get_changelog_with_version_range(): void
{
$changelogContent = <<<'MD'
# Changelog
## [1.3.0] - 2026-03-15
### Added
- 기능 E
## [1.2.0] - 2026-03-01
### Added
- 기능 D
## [1.1.0] - 2026-02-15
### Added
- 기능 C
## [1.0.0] - 2026-01-01
### Added
- 초기 릴리스
MD;
$changelogPath = base_path('CHANGELOG.md');
$originalExists = File::exists($changelogPath);
$originalContent = $originalExists ? File::get($changelogPath) : null;
File::put($changelogPath, $changelogContent);
$this->tempFiles[] = $changelogPath;
// 캐시 파일이 존재하면 임시 제거 (캐시가 로컬보다 우선하므로)
$cachePath = storage_path('app/temp/core_remote_changelog.md');
$cacheExists = File::exists($cachePath);
$cacheContent = $cacheExists ? File::get($cachePath) : null;
if ($cacheExists) {
File::delete($cachePath);
}
try {
// 1.1.0 초과 ~ 1.3.0 이하 범위 필터링
$result = $this->service->getChangelog('1.1.0', '1.3.0');
$this->assertIsArray($result);
$this->assertCount(2, $result);
$versions = array_column($result, 'version');
$this->assertContains('1.2.0', $versions);
$this->assertContains('1.3.0', $versions);
$this->assertNotContains('1.1.0', $versions);
$this->assertNotContains('1.0.0', $versions);
} finally {
if ($originalContent !== null) {
File::put($changelogPath, $originalContent);
}
// 캐시 파일 복원
if ($cacheContent !== null) {
File::put($cachePath, $cacheContent);
}
$this->tempFiles = array_filter($this->tempFiles, fn ($f) => $f !== $changelogPath);
}
}
/**
* CHANGELOG.md 파일이 없을 때 빈 배열을 반환하는지 검증합니다.
*/
public function test_get_changelog_returns_empty_when_file_missing(): void
{
// CHANGELOG.md가 없는 경로를 시뮬레이션하기 위해
// 실제 파일이 존재하는 경우 임시로 이름 변경
$changelogPath = base_path('CHANGELOG.md');
$backupPath = base_path('CHANGELOG.md.bak_test');
$renamed = false;
if (File::exists($changelogPath)) {
File::move($changelogPath, $backupPath);
$renamed = true;
}
try {
$result = $this->service->getChangelog();
$this->assertIsArray($result);
$this->assertEmpty($result);
} finally {
if ($renamed && File::exists($backupPath)) {
File::move($backupPath, $changelogPath);
}
}
}
// ========================================================================
// validatePendingPath() - _pending 디렉토리 검증
// ========================================================================
/**
* 존재하지 않는 _pending 디렉토리를 자동 생성하는지 검증합니다.
*/
public function test_validate_pending_path_creates_directory(): void
{
$tempPendingPath = storage_path('test_pending_'.uniqid());
$this->tempDirs[] = $tempPendingPath;
config(['app.update.pending_path' => $tempPendingPath]);
// 디렉토리가 존재하지 않음을 확인
$this->assertFalse(File::isDirectory($tempPendingPath));
$result = $this->service->validatePendingPath();
// 반환 구조 검증
$this->assertArrayHasKey('valid', $result);
$this->assertArrayHasKey('path', $result);
$this->assertArrayHasKey('errors', $result);
$this->assertArrayHasKey('owner', $result);
$this->assertArrayHasKey('group', $result);
$this->assertArrayHasKey('permissions', $result);
// 경로 확인
$this->assertEquals($tempPendingPath, $result['path']);
// 디렉토리가 생성되었는지 확인
$this->assertTrue(File::isDirectory($tempPendingPath));
// valid 상태 확인
$this->assertTrue($result['valid']);
$this->assertEmpty($result['errors']);
}
// ========================================================================
// enableMaintenanceMode() / disableMaintenanceMode() - 유지보수 모드
// ========================================================================
/**
* 유지보수 모드 활성화/비활성화가 정상 동작하는지 검증합니다.
*/
public function test_enable_disable_maintenance_mode(): void
{
// enableMaintenanceMode: Artisan::call('down', ...) 호출 검증
Artisan::shouldReceive('call')
->once()
->with('down', \Mockery::on(function ($args) {
return isset($args['--secret'])
&& isset($args['--retry'])
&& $args['--retry'] === 60
&& isset($args['--refresh'])
&& $args['--refresh'] === 15;
}));
$secret = $this->service->enableMaintenanceMode();
// secret은 UUID 형식이어야 함
$this->assertIsString($secret);
$this->assertMatchesRegularExpression(
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/',
$secret
);
// disableMaintenanceMode: Artisan::call('up') 호출 검증
Artisan::shouldReceive('call')
->once()
->with('up');
$this->service->disableMaintenanceMode();
}
// ========================================================================
// updateVersionInEnv() - .env 파일 버전 갱신
// ========================================================================
/**
* .env 파일의 APP_VERSION 값을 올바르게 갱신하는지 검증합니다.
*
* 실제 프로젝트 루트의 `.env` 를 절대 건드리지 않기 위해 임시 디렉토리로
* `app()->setBasePath()` 를 일시적으로 전환한 뒤 검증합니다. 과거 본 테스트가
* `base_path('.env')` 를 직접 덮어쓰는 구조였고, 테스트 실행이 중단되면
* `finally` 의 복원 로직이 손상된 fixture 를 그대로 다시 기록해 영구 고착되는
* 자기 영속화 결함이 있어 회귀 차단을 위해 격리 기반으로 재작성됨.
*/
public function test_update_version_in_env(): void
{
$tempEnvContent = "APP_NAME=G7\nAPP_VERSION=1.0.0\nAPP_ENV=testing\n";
$this->withIsolatedBasePath(function (string $tempBase) use ($tempEnvContent): void {
File::put($tempBase.DIRECTORY_SEPARATOR.'.env', $tempEnvContent);
$this->service->updateVersionInEnv('2.5.0');
$updatedContent = File::get($tempBase.DIRECTORY_SEPARATOR.'.env');
// APP_VERSION 이 갱신되었는지 확인
$this->assertStringContainsString('APP_VERSION=2.5.0', $updatedContent);
$this->assertStringNotContainsString('APP_VERSION=1.0.0', $updatedContent);
// 다른 설정은 유지되는지 확인
$this->assertStringContainsString('APP_NAME=G7', $updatedContent);
$this->assertStringContainsString('APP_ENV=testing', $updatedContent);
});
}
/**
* .env 파일에 APP_VERSION 이 없을 때 새로 추가하는지 검증합니다.
*/
public function test_update_version_in_env_appends_when_missing(): void
{
$tempEnvContent = "APP_NAME=G7\nAPP_ENV=testing\n";
$this->withIsolatedBasePath(function (string $tempBase) use ($tempEnvContent): void {
File::put($tempBase.DIRECTORY_SEPARATOR.'.env', $tempEnvContent);
$this->service->updateVersionInEnv('1.5.0');
$updatedContent = File::get($tempBase.DIRECTORY_SEPARATOR.'.env');
// APP_VERSION 이 추가되었는지 확인
$this->assertStringContainsString('APP_VERSION=1.5.0', $updatedContent);
});
}
/**
* 회귀 테스트 — `updateVersionInEnv` 가 실 프로젝트 루트의 `.env` 를
* 어떤 경우에도 수정하지 않는지 검증합니다.
*
* 본 테스트가 실행되어도 진짜 `.env` 의 mtime/내용 모두 불변이어야 합니다.
* 과거 회귀 (테스트 fixture 가 실 `.env` 를 덮어쓰고 finally 복원 실패 시
* 영구 고착) 의 재발을 차단합니다.
*/
public function test_update_version_in_env_never_touches_real_env(): void
{
$realEnvPath = base_path('.env');
$existedBefore = File::exists($realEnvPath);
$contentBefore = $existedBefore ? File::get($realEnvPath) : null;
$mtimeBefore = $existedBefore ? File::lastModified($realEnvPath) : null;
$this->test_update_version_in_env();
$this->test_update_version_in_env_appends_when_missing();
$this->assertSame($existedBefore, File::exists($realEnvPath), '실 .env 존재 여부가 변경되었습니다');
if ($existedBefore) {
$this->assertSame($contentBefore, File::get($realEnvPath), '실 .env 내용이 변경되었습니다');
$this->assertSame($mtimeBefore, File::lastModified($realEnvPath), '실 .env mtime 이 변경되었습니다');
}
}
/**
* 임시 디렉토리로 application base path 를 격리한 채 콜백을 실행합니다.
*
* `setBasePath` 는 `path.base` 등 다수 컨테이너 바인딩을 재바인딩하지만,
* 본 테스트가 사용하는 helper (base_path) 만 새 경로를 따라가도록 충분합니다.
* finally 로 원본 base path 를 복원하고 임시 디렉토리는 tempDirs 에 등록되어
* tearDown 에서 일괄 정리됩니다.
*
* @param \Closure(string $tempBase): void $callback
*/
private function withIsolatedBasePath(\Closure $callback): void
{
$tempBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7-core-update-test-'.uniqid('', true);
File::makeDirectory($tempBase, 0755, true);
$this->tempDirs[] = $tempBase;
$originalBase = $this->app->basePath();
$this->app->setBasePath($tempBase);
try {
$callback($tempBase);
} finally {
$this->app->setBasePath($originalBase);
}
}
// ========================================================================
// generateFailureReport() - 실패 리포트 생성
// ========================================================================
/**
* 업데이트 실패 리포트 파일이 올바르게 생성되는지 검증합니다.
*/
public function test_generate_failure_report_creates_log_file(): void
{
$exception = new \RuntimeException('테스트 오류 메시지');
$reportPath = $this->service->generateFailureReport($exception, '1.0.0', '2.0.0');
$this->tempFiles[] = $reportPath;
// 파일이 생성되었는지 확인
$this->assertTrue(File::exists($reportPath));
// 파일 경로가 storage/logs 하위인지 확인
$this->assertStringStartsWith(storage_path('logs'), $reportPath);
$this->assertStringContainsString('core_update_failure_', $reportPath);
$this->assertStringEndsWith('.log', $reportPath);
// 파일 내용 검증
$content = File::get($reportPath);
$this->assertStringContainsString('그누보드7 코어 업데이트 실패 리포트', $content);
$this->assertStringContainsString('시작 버전: 1.0.0', $content);
$this->assertStringContainsString('대상 버전: 2.0.0', $content);
$this->assertStringContainsString('테스트 오류 메시지', $content);
$this->assertStringContainsString('PHP: '.PHP_VERSION, $content);
$this->assertStringContainsString('스택 트레이스', $content);
}
// ========================================================================
// cleanupPending() - _pending 디렉토리 정리 (타임스탬프 기반)
// ========================================================================
/**
* 타임스탬프 기반 pending 디렉토리가 통째로 삭제되는지 검증합니다.
*
* cleanupPending(string $pendingPath)는 지정된 경로를 전체 삭제합니다.
*/
public function test_cleanup_pending_removes_directory(): void
{
$tempPendingPath = storage_path('test_pending_cleanup_'.uniqid());
$this->tempDirs[] = $tempPendingPath;
// 임시 디렉토리 및 파일 생성
File::ensureDirectoryExists($tempPendingPath);
File::put($tempPendingPath.DIRECTORY_SEPARATOR.'test_file.txt', '테스트 내용');
// 디렉토리 존재 확인
$this->assertTrue(File::isDirectory($tempPendingPath));
$this->service->cleanupPending($tempPendingPath);
// 디렉토리 전체가 삭제됨
$this->assertFalse(File::isDirectory($tempPendingPath));
}
/**
* 존재하지 않는 경로에 대해 cleanupPending()이 예외 없이 처리되는지 검증합니다.
*/
public function test_cleanup_pending_does_not_fail_when_directory_missing(): void
{
$nonExistentPath = storage_path('non_existent_pending_'.uniqid());
// 예외 없이 정상 실행되는지 확인
$this->service->cleanupPending($nonExistentPath);
$this->assertFalse(File::isDirectory($nonExistentPath));
}
// ========================================================================
// createPendingDirectory() - 타임스탬프 기반 pending 디렉토리 생성
// ========================================================================
/**
* createPendingDirectory()가 타임스탬프 형식의 디렉토리를 생성하는지 검증합니다.
*/
public function test_create_pending_directory_creates_timestamped_directory(): void
{
$basePath = storage_path('test_pending_base_'.uniqid());
$this->tempDirs[] = $basePath;
File::ensureDirectoryExists($basePath);
config(['app.update.pending_path' => $basePath]);
$pendingPath = $this->service->createPendingDirectory();
$this->tempDirs[] = $pendingPath;
// 디렉토리가 생성되었는지 확인
$this->assertTrue(File::isDirectory($pendingPath));
// 경로가 basePath 하위인지 확인
$this->assertStringStartsWith($basePath, $pendingPath);
// core_ 접두사 + 타임스탬프 형식인지 확인
$dirName = basename($pendingPath);
$this->assertMatchesRegularExpression('/^core_\d{8}_\d{6}$/', $dirName);
}
/**
* createPendingDirectory()가 매 호출마다 고유한 경로를 반환하는지 검증합니다.
*/
public function test_create_pending_directory_returns_unique_paths(): void
{
$basePath = storage_path('test_pending_unique_'.uniqid());
$this->tempDirs[] = $basePath;
File::ensureDirectoryExists($basePath);
config(['app.update.pending_path' => $basePath]);
$path1 = $this->service->createPendingDirectory();
$this->tempDirs[] = $path1;
// 1초 대기하여 다른 타임스탬프 보장
sleep(1);
$path2 = $this->service->createPendingDirectory();
$this->tempDirs[] = $path2;
$this->assertNotEquals($path1, $path2);
$this->assertTrue(File::isDirectory($path1));
$this->assertTrue(File::isDirectory($path2));
}
// ========================================================================
// clearAllCaches() - 모든 캐시 초기화
// ========================================================================
/**
* 모든 캐시 초기화 및 패키지 재생성 명령이 순서대로 호출되는지 검증합니다.
*/
public function test_clear_all_caches_calls_artisan_commands(): void
{
Artisan::shouldReceive('call')->once()->with('config:clear');
Artisan::shouldReceive('call')->once()->with('cache:clear');
Artisan::shouldReceive('call')->once()->with('route:clear');
Artisan::shouldReceive('call')->once()->with('view:clear');
Artisan::shouldReceive('call')->once()->with('package:discover');
Artisan::shouldReceive('call')->once()->with('extension:update-autoload');
$this->service->clearAllCaches();
}
// ========================================================================
// core:check-updates 커맨드 - 에러 출력 검증
// ========================================================================
/**
* 업데이트 확인 성공 시 커맨드가 정상 종료되는지 검증합니다.
*/
public function test_check_updates_command_shows_success(): void
{
config(['app.update.github_url' => 'https://github.com/test/repo']);
$this->instance(CoreUpdateService::class, new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => '7.0.0-alpha.1', 'error' => null];
}
});
$this->artisan('core:check-updates')
->expectsOutputToContain('현재 최신 버전입니다.')
->assertExitCode(0);
}
/**
* 업데이트 확인 실패 시 커맨드가 에러 메시지를 출력하는지 검증합니다.
*/
public function test_check_updates_command_shows_error_on_failure(): void
{
config(['app.update.github_url' => 'https://github.com/test/repo']);
$this->instance(CoreUpdateService::class, new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => null, 'error' => 'GitHub 저장소를 찾을 수 없습니다.'];
}
});
$this->artisan('core:check-updates')
->expectsOutputToContain('업데이트 확인 실패: GitHub 저장소를 찾을 수 없습니다.')
->assertExitCode(1);
}
/**
* core:update 커맨드에서 업데이트 확인 실패 시 에러 출력 후 종료하는지 검증합니다.
*/
public function test_update_command_shows_error_on_check_failure(): void
{
config(['app.update.github_url' => 'https://github.com/test/repo']);
$this->instance(CoreUpdateService::class, new class extends CoreUpdateService
{
public function checkSystemRequirements(): array
{
return ['valid' => true, 'errors' => [], 'available_methods' => ['ZipArchive']];
}
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => null, 'error' => 'GitHub 저장소를 찾을 수 없습니다.'];
}
});
$this->artisan('core:update')
->expectsOutputToContain('업데이트 확인 실패')
->assertExitCode(1);
}
/**
* 토큰 인증 실패 시 커맨드가 적절한 에러를 출력하는지 검증합니다.
*/
public function test_check_updates_command_shows_auth_error(): void
{
config(['app.update.github_url' => 'https://github.com/test/private-repo']);
$this->instance(CoreUpdateService::class, new class extends CoreUpdateService
{
protected function fetchLatestVersionFromGithub(string $githubUrl): array
{
return ['version' => null, 'error' => __('settings.core_update.github_token_invalid')];
}
});
$this->artisan('core:check-updates')
->expectsOutputToContain('업데이트 확인 실패')
->assertExitCode(1);
}
// ========================================================================
// targets 설정 검증
// ========================================================================
/**
* targets에 public 디렉토리가 통합 포함되고 분할 항목이 없는지 검증합니다.
*/
public function test_update_targets_includes_public_directory(): void
{
$targets = config('app.update.targets');
// public 디렉토리가 통합 포함
$this->assertContains('public', $targets);
// 이전 분할 항목이 제거됨
$this->assertNotContains('public/build', $targets);
$this->assertNotContains('public/index.php', $targets);
$this->assertNotContains('public/install', $targets);
}
/**
* targets에 라라벨 기본 탑재 파일이 모두 포함되는지 검증합니다.
*/
public function test_update_targets_includes_all_laravel_default_files(): void
{
$targets = config('app.update.targets');
$laravelDefaults = [
'artisan',
'composer.json',
'composer.lock',
'package.json',
'package-lock.json',
'vite.config.js',
'phpunit.xml',
'.editorconfig',
'.gitattributes',
'.gitignore',
'README.md',
];
foreach ($laravelDefaults as $file) {
$this->assertContains($file, $targets, "라라벨 기본 파일 '{$file}'이 targets에 포함되어야 합니다.");
}
}
/**
* targets에 코어 추가 파일이 모두 포함되는지 검증합니다.
*/
public function test_update_targets_includes_additional_core_files(): void
{
$targets = config('app.update.targets');
$coreAdditional = [
'vite.config.core.js',
'vitest.config.ts',
'tsconfig.json',
'composer.json.default',
'tests',
'docs',
];
foreach ($coreAdditional as $item) {
$this->assertContains($item, $targets, "코어 추가 항목 '{$item}'이 targets에 포함되어야 합니다.");
}
}
// ========================================================================
// applyUpdate() — removeOrphans 동작 검증
// ========================================================================
// ========================================================================
// checkSystemRequirements() — 시스템 요구사항 검증
// ========================================================================
/**
* 현재 환경에서 시스템 요구사항 검증이 올바른 구조를 반환하는지 확인합니다.
*/
public function test_check_system_requirements_returns_correct_structure(): void
{
$result = $this->service->checkSystemRequirements();
$this->assertArrayHasKey('valid', $result);
$this->assertArrayHasKey('errors', $result);
$this->assertArrayHasKey('available_methods', $result);
$this->assertIsBool($result['valid']);
$this->assertIsArray($result['errors']);
$this->assertIsArray($result['available_methods']);
}
// ========================================================================
// buildExtractionStrategies() — 추출 전략 빌드
// ========================================================================
/**
* 추출 전략에 PharData가 포함되지 않는지 검증합니다 (tar 경로 길이 제한으로 제거됨).
*/
public function test_build_extraction_strategies_does_not_include_phardata(): void
{
$strategies = $this->invokeProtectedMethod($this->service, 'buildExtractionStrategies');
$labels = array_map(fn ($s) => $s['label'], $strategies);
$this->assertNotContains('PharData', $labels);
}
/**
* ZipArchive 사용 가능 시 전략에 포함되는지 검증합니다.
*/
public function test_build_extraction_strategies_includes_ziparchive_when_available(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$strategies = $this->invokeProtectedMethod($this->service, 'buildExtractionStrategies');
$labels = array_map(fn ($s) => $s['label'], $strategies);
$this->assertContains('ZipArchive', $labels);
}
/**
* 모든 전략이 zipball 타입을 사용하는지 검증합니다.
*/
public function test_build_extraction_strategies_all_use_zipball(): void
{
$strategies = $this->invokeProtectedMethod($this->service, 'buildExtractionStrategies');
if (empty($strategies)) {
$this->markTestSkipped('ZipArchive/unzip이 없는 환경에서는 전략이 생성되지 않습니다.');
}
foreach ($strategies as $strategy) {
$this->assertEquals('zipball', $strategy['archive_type'], "{$strategy['label']}은 zipball 타입이어야 합니다.");
}
}
// ========================================================================
// extractWithZipArchive() — ZipArchive 추출 검증
// ========================================================================
/**
* ZipArchive로 실제 ZIP 파일을 추출할 수 있는지 검증합니다.
*/
public function test_extract_with_zip_archive(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$tempDir = storage_path('test_zip_extract_'.uniqid());
$this->tempDirs[] = $tempDir;
File::ensureDirectoryExists($tempDir);
// 테스트 ZIP 생성
$zipPath = $tempDir.DIRECTORY_SEPARATOR.'test.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('test-dir/hello.txt', 'Hello World');
$zip->close();
$extractDir = $tempDir.DIRECTORY_SEPARATOR.'extracted';
File::ensureDirectoryExists($extractDir);
$this->invokeProtectedMethod($this->service, 'extractWithZipArchive', [$zipPath, $extractDir]);
$this->assertTrue(File::exists($extractDir.DIRECTORY_SEPARATOR.'test-dir'.DIRECTORY_SEPARATOR.'hello.txt'));
$this->assertEquals('Hello World', File::get($extractDir.DIRECTORY_SEPARATOR.'test-dir'.DIRECTORY_SEPARATOR.'hello.txt'));
}
// ========================================================================
// validatePendingUpdate() — 패키지 검증 (G7 프로젝트 확인)
// ========================================================================
/**
* 유효한 G7 패키지 디렉토리가 검증을 통과하는지 검증합니다.
*/
public function test_validate_pending_update_passes_for_valid_g7_package(): void
{
$tempDir = storage_path('test_validate_g7_'.uniqid());
$this->tempDirs[] = $tempDir;
File::ensureDirectoryExists($tempDir.DIRECTORY_SEPARATOR.'app');
File::ensureDirectoryExists($tempDir.DIRECTORY_SEPARATOR.'config');
File::put($tempDir.DIRECTORY_SEPARATOR.'composer.json', '{}');
File::put($tempDir.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php', "<?php\nreturn ['version' => '1.0.0'];");
// 예외 없이 통과
$this->service->validatePendingUpdate($tempDir);
$this->assertTrue(true);
}
/**
* config/app.php가 없는 디렉토리에서 예외가 발생하는지 검증합니다.
*/
public function test_validate_pending_update_fails_for_non_g7_package(): void
{
$tempDir = storage_path('test_validate_non_g7_'.uniqid());
$this->tempDirs[] = $tempDir;
File::ensureDirectoryExists($tempDir.DIRECTORY_SEPARATOR.'app');
File::put($tempDir.DIRECTORY_SEPARATOR.'composer.json', '{}');
// config/app.php 없음
$this->expectException(\RuntimeException::class);
$this->service->validatePendingUpdate($tempDir);
}
/**
* config/app.php에 version 키가 없을 때 예외가 발생하는지 검증합니다.
*/
public function test_validate_pending_update_fails_without_version_key(): void
{
$tempDir = storage_path('test_validate_no_version_'.uniqid());
$this->tempDirs[] = $tempDir;
File::ensureDirectoryExists($tempDir.DIRECTORY_SEPARATOR.'app');
File::ensureDirectoryExists($tempDir.DIRECTORY_SEPARATOR.'config');
File::put($tempDir.DIRECTORY_SEPARATOR.'composer.json', '{}');
File::put($tempDir.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php', "<?php\nreturn ['name' => 'NotG7'];");
$this->expectException(\RuntimeException::class);
$this->service->validatePendingUpdate($tempDir);
}
// ========================================================================
// 헬퍼 메서드
// ========================================================================
/**
* protected/private 메서드를 호출합니다.
*
* @param object $object 대상 객체
* @param string $method 메서드명
* @param array $args 인수
*/
private function invokeProtectedMethod(object $object, string $method, array $args = []): mixed
{
$reflection = new \ReflectionMethod($object, $method);
$reflection->setAccessible(true);
return $reflection->invoke($object, ...$args);
}
// ========================================================================
// extractZipToPending() — 외부 ZIP 추출
// ========================================================================
/**
* 평탄 루트 G7 ZIP 을 _pending 으로 추출하고 검증을 통과하는지 확인합니다.
*/
public function test_extract_zip_to_pending_flat_layout(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$pendingBase = storage_path('test_core_zip_pending_'.uniqid());
$this->tempDirs[] = $pendingBase;
File::ensureDirectoryExists($pendingBase);
config(['app.update.pending_path' => $pendingBase]);
$zipDir = storage_path('test_core_zip_src_'.uniqid());
$this->tempDirs[] = $zipDir;
File::ensureDirectoryExists($zipDir);
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'g7.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('composer.json', '{"name":"g7/core"}');
$zip->addFromString('app/.gitkeep', '');
$zip->addFromString('config/app.php', "<?php\nreturn ['version' => '9.9.9'];");
$zip->close();
$result = $this->service->extractZipToPending($zipPath);
$this->assertTrue(File::exists($result.DIRECTORY_SEPARATOR.'composer.json'));
$this->assertTrue(File::isDirectory($result.DIRECTORY_SEPARATOR.'app'));
}
/**
* 래퍼 디렉토리(owner-repo-hash/) 를 자동 감지하여 그 내부를 반환하는지 검증합니다.
*/
public function test_extract_zip_to_pending_unwraps_wrapper_directory(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$pendingBase = storage_path('test_core_zip_pending_wrap_'.uniqid());
$this->tempDirs[] = $pendingBase;
File::ensureDirectoryExists($pendingBase);
config(['app.update.pending_path' => $pendingBase]);
$zipDir = storage_path('test_core_zip_wrap_src_'.uniqid());
$this->tempDirs[] = $zipDir;
File::ensureDirectoryExists($zipDir);
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'g7-wrap.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('g7-core-abc123/composer.json', '{"name":"g7/core"}');
$zip->addFromString('g7-core-abc123/app/.gitkeep', '');
$zip->addFromString('g7-core-abc123/config/app.php', "<?php\nreturn ['version' => '7.0.1'];");
$zip->close();
$result = $this->service->extractZipToPending($zipPath);
$this->assertStringEndsWith('g7-core-abc123', $result);
$this->assertTrue(File::exists($result.DIRECTORY_SEPARATOR.'composer.json'));
}
/**
* ZIP 파일이 존재하지 않을 때 RuntimeException 이 발생하는지 검증합니다.
*/
public function test_extract_zip_to_pending_throws_when_zip_missing(): void
{
$this->expectException(\RuntimeException::class);
$this->service->extractZipToPending(storage_path('nonexistent_'.uniqid().'.zip'));
}
/**
* ZIP 내용이 G7 패키지가 아닐 때 검증에서 RuntimeException 이 발생하는지 확인합니다.
*/
public function test_extract_zip_to_pending_throws_for_invalid_package(): void
{
if (! class_exists(\ZipArchive::class)) {
$this->markTestSkipped('ZipArchive not available');
}
$pendingBase = storage_path('test_core_zip_pending_invalid_'.uniqid());
$this->tempDirs[] = $pendingBase;
File::ensureDirectoryExists($pendingBase);
config(['app.update.pending_path' => $pendingBase]);
$zipDir = storage_path('test_core_zip_invalid_src_'.uniqid());
$this->tempDirs[] = $zipDir;
File::ensureDirectoryExists($zipDir);
$zipPath = $zipDir.DIRECTORY_SEPARATOR.'invalid.zip';
$zip = new \ZipArchive;
$zip->open($zipPath, \ZipArchive::CREATE);
$zip->addFromString('README.md', '# not g7');
$zip->close();
$this->expectException(\RuntimeException::class);
$this->service->extractZipToPending($zipPath);
}
/**
* applyUpdate가 소스에 없는 파일을 삭제하는지 검증합니다.
*/
public function test_apply_update_removes_orphan_files(): void
{
$tempSource = storage_path('test_apply_source_'.uniqid());
$this->tempDirs[] = $tempSource;
// 소스에 app 디렉토리와 파일 생성
File::ensureDirectoryExists($tempSource.DIRECTORY_SEPARATOR.'app');
File::put($tempSource.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'NewFile.php', '<?php // new');
// 대상(base_path)에 orphan 파일 시뮬레이션을 위해 임시 디렉토리 사용
$tempDest = storage_path('test_apply_dest_'.uniqid());
$this->tempDirs[] = $tempDest;
File::ensureDirectoryExists($tempDest.DIRECTORY_SEPARATOR.'app');
File::put($tempDest.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'NewFile.php', '<?php // old');
File::put($tempDest.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'OrphanFile.php', '<?php // orphan');
// FilePermissionHelper::copyDirectory 직접 호출로 검증
FilePermissionHelper::copyDirectory(
$tempSource.DIRECTORY_SEPARATOR.'app',
$tempDest.DIRECTORY_SEPARATOR.'app',
removeOrphans: true
);
// NewFile.php는 소스 내용으로 교체
$this->assertEquals('<?php // new', File::get($tempDest.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'NewFile.php'));
// OrphanFile.php는 삭제됨
$this->assertFalse(File::exists($tempDest.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'OrphanFile.php'));
}
// ========================================================================
// applyUpdate() — 기본(증분) 3-way 적용 + --prune (공개 #64)
// ========================================================================
/**
* 증분 모드(applyList 지정): 코어가 변경하지 않은 파일(applyList 미포함)은
* 스킵되어 사용자가 수정한 현재 디스크 내용이 보존됩니다.
*
* 이슈 #64 핵심 회귀 가드 — `.htaccess` 커스텀 블록 소실 방지.
*/
public function test_apply_update_incremental_preserves_user_modified_unchanged_file(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['public']);
try {
// source(theirs): .htaccess 는 코어가 변경하지 않았다고 가정 → applyList 미포함
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'public');
File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess', "# core default\n");
File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "<?php // core v2\n");
// 활성(mine): 사용자가 .htaccess 에 커스텀 블록을 넣은 상태
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'public');
File::put($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess', "# core default\n# USER CUSTOM CACHE BLOCK\n");
File::put($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "<?php // core v1\n");
// 코어가 실제로 바꾼 파일은 index.php 뿐 (.htaccess 는 목록에 없음)
$applyList = ['public/index.php'];
$this->service->applyUpdate($source, null, prune: false, applyList: $applyList);
// .htaccess: 사용자 커스텀 블록 보존 (스킵)
$this->assertStringContainsString(
'USER CUSTOM CACHE BLOCK',
File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'.htaccess'),
'.htaccess 는 applyList 에 없으므로 사용자 수정이 보존되어야 한다',
);
// index.php: 코어 변경분 적용
$this->assertStringContainsString(
'core v2',
File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php'),
);
} finally {
$restore();
}
}
/**
* 증분 모드: applyList 에 없는 신규 파일이 source 에 있어도 스킵됩니다.
* (코어가 실제 추가했다면 applyList 에 담겼을 것 — 목록이 최종 권위)
*/
public function test_apply_update_incremental_applies_only_listed_files(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']);
try {
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Listed.php', '<?php // listed');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Unlisted.php', '<?php // unlisted');
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
$this->service->applyUpdate($source, null, prune: false, applyList: ['app/Listed.php']);
$this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Listed.php');
$this->assertFileDoesNotExist(
$fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Unlisted.php',
'applyList 에 없는 파일은 복사되지 않아야 한다',
);
} finally {
$restore();
}
}
/**
* end-to-end 회귀 (공개 #64 / 내부 #452): 코어 업데이트가 번들 확장의 변경된
* `_bundled` 파일을 실제로 반영합니다.
*
* computeApplyList(3-way 산출) → applyUpdate(적용) 전 체인을 실제 config 조합
* (protected 에 `modules`, targets 에 `modules/_bundled`)으로 검증한다.
* protected 필터가 `_bundled` 갱신을 삼키던 결함으로 인해, 코어 배포본에
* 새 번들(vendor-bundle.json/composer.json 갱신)이 포함돼도 활성 서버의
* `_bundled` 가 갱신되지 않아 이후 `module:update` 무결성 검증이 실패하던
* 회귀를 차단한다.
*/
public function test_incremental_apply_reflects_changed_bundled_extension_file(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['modules/_bundled']);
try {
// 실제 config 조합 재현: protected 에 부모 도메인 포함
config(['app.update.protected_paths' => ['.env', 'storage', 'vendor', 'modules', 'plugins', 'templates', 'lang-packs']]);
$rel = 'modules/_bundled/sirsoft-ecommerce/vendor-bundle.json';
$relPlatform = str_replace('/', DIRECTORY_SEPARATOR, $rel);
// base(구버전) = 옛 해시, source(신버전) = 새 해시 (코어가 변경한 번들 파일)
File::ensureDirectoryExists(dirname($fakeBase.DIRECTORY_SEPARATOR.$relPlatform));
File::put($fakeBase.DIRECTORY_SEPARATOR.$relPlatform, '{"composer_json_sha256":"OLD"}');
File::ensureDirectoryExists(dirname($source.DIRECTORY_SEPARATOR.$relPlatform));
File::put($source.DIRECTORY_SEPARATOR.$relPlatform, '{"composer_json_sha256":"NEW-differs-in-length"}');
// 3-way 산출: base != theirs → changed 로 목록 포함되어야 한다
$applyResult = CoreBackupHelper::computeApplyList(
$fakeBase,
$source,
config('app.update.targets'),
config('app.update.protected_paths'),
config('app.update.excludes'),
);
$this->assertContains($rel, $applyResult['apply'], 'changed 된 _bundled 파일이 apply 목록에 포함되어야 한다');
// 적용: 활성(base) 파일이 신버전 내용으로 갱신되어야 한다
$this->service->applyUpdate($source, null, prune: false, applyList: $applyResult['apply']);
$this->assertSame(
'{"composer_json_sha256":"NEW-differs-in-length"}',
File::get($fakeBase.DIRECTORY_SEPARATOR.$relPlatform),
'코어 업데이트가 변경된 _bundled 번들 파일을 실제로 반영해야 한다',
);
} finally {
$restore();
}
}
/**
* 증분 모드는 orphan(소스에 없는 대상 파일)을 삭제하지 않습니다
* (사용자가 추가한 신규 파일 보존).
*/
public function test_apply_update_incremental_does_not_remove_orphans(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']);
try {
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', '<?php // core');
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'UserAdded.php', '<?php // user file');
$this->service->applyUpdate($source, null, prune: false, applyList: ['app/Core.php']);
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'UserAdded.php',
'증분 모드는 orphan 을 삭제하지 않아야 한다',
);
} finally {
$restore();
}
}
/**
* prune=true: 전체 덮어쓰기 + orphan 삭제 (기존 동작).
*/
public function test_apply_update_prune_removes_orphans_and_overwrites_all(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']);
try {
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', '<?php // core v2');
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php', '<?php // core v1');
File::put($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Orphan.php', '<?php // orphan');
// prune 모드는 applyList 를 무시하고 전체 덮어쓰기
$this->service->applyUpdate($source, null, prune: true, applyList: ['app/Core.php']);
$this->assertStringContainsString('core v2', File::get($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Core.php'));
$this->assertFileDoesNotExist(
$fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Orphan.php',
'prune 모드는 orphan 을 삭제해야 한다',
);
} finally {
$restore();
}
}
/**
* applyList=null (백업 부재 fallback): prune 이 아니어도 전체 덮어쓰기로 회귀.
*/
public function test_apply_update_null_apply_list_falls_back_to_full_overwrite(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['app']);
try {
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'app');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'A.php', '<?php // a');
File::put($source.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'B.php', '<?php // b');
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'app');
// applyList 미지정(null) → incremental=false → 전체 복사
$this->service->applyUpdate($source, null, prune: false, applyList: null);
$this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'A.php');
$this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'B.php');
} finally {
$restore();
}
}
/**
* applyUpdate 환경(격리 base_path + config targets)을 준비합니다.
*
* @param array<int, string> $targets config('app.update.targets') 로 설정할 값
* @return array{0:string, 1:string, 2:\Closure} [source 경로, fakeBase 경로, 복원 클로저]
*/
private function prepareApplyUpdateEnv(array $targets): array
{
$source = storage_path('test_apply3_src_'.uniqid());
$fakeBase = storage_path('test_apply3_base_'.uniqid());
$this->tempDirs[] = $source;
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($source);
File::ensureDirectoryExists($fakeBase);
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
config([
'app.update.targets' => $targets,
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
$restore = function () use ($originalBasePath): void {
app()->setBasePath($originalBasePath);
};
return [$source, $fakeBase, $restore];
}
// ========================================================================
// applyUpdate() — 신규 최상위 디렉토리 자동 발견 폴백 (회귀: lang-packs 누락)
// ========================================================================
/**
* 부모 프로세스의 stale `app.update.targets` 가 신버전 신규 최상위 디렉토리를 인식하지
* 못할 때, applyUpdate 가 source 디렉토리에서 자동 발견하여 복사하는지 검증합니다.
*
* 회귀 시나리오: beta.3 → beta.4 업그레이드에서 beta.3 의 targets 에 `lang-packs/_bundled`
* 가 없어 신버전 zip 의 lang-packs 가 활성 디렉토리로 복사되지 않은 결함.
*/
public function test_apply_update_auto_discovers_new_top_level_directories(): void
{
$sourcePath = storage_path('test_apply_autodisc_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
// 신버전 source 에 신규 최상위 디렉토리 + 보호 대상 디렉토리 + 파일 배치
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'lang-packs'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'g7-core-ja');
File::put(
$sourcePath.DIRECTORY_SEPARATOR.'lang-packs'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'g7-core-ja'.DIRECTORY_SEPARATOR.'language-pack.json',
'{"identifier":"g7-core-ja"}'
);
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app');
File::put($sourcePath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'leak.txt', 'should_not_copy');
File::put($sourcePath.DIRECTORY_SEPARATOR.'.env', 'APP_VERSION=hijacked');
// 격리된 base_path 시뮬레이션
$fakeBase = storage_path('test_apply_autodisc_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
// 활성 측에 보호 대상 dir 가 이미 존재하고 내용이 있는 상태 (덮어쓰면 안 됨)
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app');
File::put($fakeBase.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'runtime.txt', 'KEEP_ME');
File::put($fakeBase.DIRECTORY_SEPARATOR.'.env', 'APP_VERSION=local');
try {
// stale targets 시뮬레이션 — beta.3 의 targets 에는 lang-packs 가 없음
config([
'app.update.targets' => ['app'],
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
// app 디렉토리도 source 에 있어야 targets 분기를 거침
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'app');
File::put($sourcePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Marker.php', '<?php // marker');
$this->service->applyUpdate($sourcePath);
// targets 명시 항목은 기존대로 적용
$this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Marker.php');
// 자동 발견: lang-packs/_bundled 가 source 에서 활성으로 복사되어야 함
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'lang-packs'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'g7-core-ja'.DIRECTORY_SEPARATOR.'language-pack.json',
'lang-packs/_bundled 가 source 에서 자동 발견되어 복사되어야 한다'
);
// PROTECTED 보호: storage/.env 는 자동 발견 폴백이 절대 덮지 않음
$this->assertSame('KEEP_ME', File::get($fakeBase.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'runtime.txt'));
$this->assertSame('APP_VERSION=local', File::get($fakeBase.DIRECTORY_SEPARATOR.'.env'));
} finally {
app()->setBasePath($originalBasePath);
}
}
/**
* targets 에 `templates/_bundled` 만 있고 source 의 `templates/` 디렉토리에 `_bundled`
* 만 있을 때, 활성 디렉토리(`templates/sirsoft-basic` 등) 가 자동 발견 폴백의
* removeOrphans 로 삭제되지 않아야 한다.
*
* 회귀 시나리오: beta.4 의 `isCoveredByApplied` 가 단방향 검사만 수행하여
* applied=['templates/_bundled'] 일 때 source 의 'templates' 자체를 신규 항목으로
* 오인 → `copyDirectory(removeOrphans:true)` 가 base 의 활성 sirsoft-* 서브디렉토리를
* orphan 으로 판정하여 삭제.
*/
public function test_apply_update_preserves_active_extension_dirs_when_source_only_has_bundled(): void
{
$sourcePath = storage_path('test_apply_preserve_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
// source: templates/_bundled/{shared-id}/manifest.json 만 보유
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-basic');
File::put(
$sourcePath.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-basic'.DIRECTORY_SEPARATOR.'manifest.json',
'{"identifier":"sirsoft-basic","version":"1.0.0"}'
);
// base: templates/_bundled/sirsoft-basic + templates/sirsoft-basic (활성 디렉토리)
$fakeBase = storage_path('test_apply_preserve_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-basic');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-basic'.DIRECTORY_SEPARATOR.'manifest.json',
'{"identifier":"sirsoft-basic","version":"0.9.0"}'
);
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'sirsoft-basic');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'sirsoft-basic'.DIRECTORY_SEPARATOR.'manifest.json',
'{"identifier":"sirsoft-basic","installed":true}'
);
try {
config([
'app.update.targets' => ['templates/_bundled'],
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
$this->service->applyUpdate($sourcePath);
// _bundled 는 source 기준으로 정상 sync
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-basic'.DIRECTORY_SEPARATOR.'manifest.json'
);
// 활성 디렉토리는 source 에 부재하지만 base 에 보존되어야 함 (회귀 차단)
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.'sirsoft-basic'.DIRECTORY_SEPARATOR.'manifest.json',
'활성 templates/sirsoft-basic 이 자동 발견 폴백에 의해 삭제되어서는 안 된다'
);
} finally {
app()->setBasePath($originalBasePath);
}
}
/**
* 회귀: 사용자가 `_bundled/` 바로 아래에 직접 생성한 커스텀 확장 디렉토리가
* 코어 업데이트의 정상 targets 흐름(removeOrphans:true)에 의해 삭제되지 않아야 한다.
*
* 확장 3종 + 언어팩 4개 도메인 전부 검증한다.
*
* 회귀 시나리오: targets 의 `{domain}/_bundled` 를 source 기준으로 sync 할 때,
* source(신버전 코어 배포본)에 없는 사용자 디렉토리(`_bundled/my-project`) 가
* orphan 으로 판정되어 삭제되던 결함 (공개 gnuboard/g7 #43 제보).
*/
public function test_apply_update_preserves_user_added_bundled_subdir_all_domains(): void
{
$domains = [
'modules' => 'sirsoft-ecommerce',
'plugins' => 'sirsoft-payment',
'templates' => 'sirsoft-basic',
'lang-packs' => 'g7-core-ja',
];
$sourcePath = storage_path('test_388_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
$fakeBase = storage_path('test_388_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
$targets = [];
foreach ($domains as $domain => $bundledId) {
$targets[] = $domain.'/_bundled';
// source: {domain}/_bundled/{bundledId} 만 보유 (코어 번들 확장)
$srcExt = $sourcePath.DIRECTORY_SEPARATOR.$domain.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.$bundledId;
File::ensureDirectoryExists($srcExt);
File::put($srcExt.DIRECTORY_SEPARATOR.'manifest.json', '{"identifier":"'.$bundledId.'","version":"1.0.0"}');
// base: 코어 번들 확장 + 사용자 추가 디렉토리(my-project) + 사용자 추가 파일(메모.txt)
$baseBundled = $fakeBase.DIRECTORY_SEPARATOR.$domain.DIRECTORY_SEPARATOR.'_bundled';
$baseExt = $baseBundled.DIRECTORY_SEPARATOR.$bundledId;
File::ensureDirectoryExists($baseExt);
File::put($baseExt.DIRECTORY_SEPARATOR.'manifest.json', '{"identifier":"'.$bundledId.'","version":"0.9.0"}');
// 사용자 추가 디렉토리 (source 에 없음)
$userDir = $baseBundled.DIRECTORY_SEPARATOR.'my-project';
File::ensureDirectoryExists($userDir);
File::put($userDir.DIRECTORY_SEPARATOR.'custom.json', '{"mine":true}');
// 사용자 추가 단일 파일 (source 에 없음)
File::put($baseBundled.DIRECTORY_SEPARATOR.'memo.txt', 'keep me');
}
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
try {
config([
'app.update.targets' => $targets,
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
$this->service->applyUpdate($sourcePath);
foreach ($domains as $domain => $bundledId) {
$baseBundled = $fakeBase.DIRECTORY_SEPARATOR.$domain.DIRECTORY_SEPARATOR.'_bundled';
// 사용자 추가 디렉토리 보존
$this->assertFileExists(
$baseBundled.DIRECTORY_SEPARATOR.'my-project'.DIRECTORY_SEPARATOR.'custom.json',
$domain.'/_bundled/my-project 가 코어 업데이트에 의해 삭제되어서는 안 된다'
);
// 사용자 추가 단일 파일 보존
$this->assertFileExists(
$baseBundled.DIRECTORY_SEPARATOR.'memo.txt',
$domain.'/_bundled/memo.txt 가 코어 업데이트에 의해 삭제되어서는 안 된다'
);
// 코어 번들 확장은 source 기준으로 정상 sync
$this->assertStringContainsString(
'"version":"1.0.0"',
File::get($baseBundled.DIRECTORY_SEPARATOR.$bundledId.DIRECTORY_SEPARATOR.'manifest.json'),
$domain.'/_bundled/'.$bundledId.' 는 source 기준으로 갱신되어야 한다'
);
}
} finally {
app()->setBasePath($originalBasePath);
}
}
/**
* 비회귀: `--prune` 모드에서 `_bundled` 최상위 사용자 항목은 보존하되, source 에
* 존재하는 코어 번들 확장 디렉토리 *내부* 의 stale 파일 정리는 그대로 유지되어야 한다.
*
* 주의: orphan(stale) 정리는 `--prune` 지정 시에만 수행된다 (공개 #64).
* 기본(증분) 모드는 orphan 을 삭제하지 않으므로 본 회귀 가드는 prune 컨텍스트로 검증한다.
* 기본 모드에서 stale 파일이 보존되는 동작은 별도 테스트
* (test_apply_update_incremental_does_not_remove_orphans)가 커버한다.
*/
public function test_apply_update_prune_still_cleans_stale_file_inside_bundled_extension(): void
{
$sourcePath = storage_path('test_388_stale_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
// source: modules/_bundled/sirsoft-ecommerce/module.json 만 (old.json 없음)
$srcExt = $sourcePath.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce';
File::ensureDirectoryExists($srcExt);
File::put($srcExt.DIRECTORY_SEPARATOR.'module.json', '{"version":"1.0.0"}');
$fakeBase = storage_path('test_388_stale_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
// base: 코어 번들 확장 내부에 source 에 없는 stale 파일(old.json) + 사용자 디렉토리
$baseBundled = $fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled';
$baseExt = $baseBundled.DIRECTORY_SEPARATOR.'sirsoft-ecommerce';
File::ensureDirectoryExists($baseExt);
File::put($baseExt.DIRECTORY_SEPARATOR.'module.json', '{"version":"0.9.0"}');
File::put($baseExt.DIRECTORY_SEPARATOR.'old.json', 'stale');
$userDir = $baseBundled.DIRECTORY_SEPARATOR.'my-project';
File::ensureDirectoryExists($userDir);
File::put($userDir.DIRECTORY_SEPARATOR.'custom.json', '{"mine":true}');
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
try {
config([
'app.update.targets' => ['modules/_bundled'],
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
// --prune 컨텍스트: orphan(stale) 정리 활성화
$this->service->applyUpdate($sourcePath, null, prune: true);
// 코어 번들 확장 내부의 stale 파일은 정리되어야 함
$this->assertFileDoesNotExist(
$baseExt.DIRECTORY_SEPARATOR.'old.json',
'--prune 모드에서 코어 번들 확장 내부의 stale 파일은 정리되어야 한다'
);
// 사용자 추가 디렉토리는 보존
$this->assertFileExists(
$userDir.DIRECTORY_SEPARATOR.'custom.json',
'사용자 추가 _bundled/my-project 는 보존되어야 한다'
);
} finally {
app()->setBasePath($originalBasePath);
}
}
/**
* modules/, plugins/ 에도 동일 패턴 보존 확인.
*/
public function test_apply_update_preserves_active_module_and_plugin_dirs_same_pattern(): void
{
$sourcePath = storage_path('test_apply_preserve_mp_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
// source: modules/_bundled, plugins/_bundled 만 보유
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce');
File::put(
$sourcePath.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce'.DIRECTORY_SEPARATOR.'module.json',
'{"identifier":"sirsoft-ecommerce","version":"1.0.0"}'
);
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-payment');
File::put(
$sourcePath.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-payment'.DIRECTORY_SEPARATOR.'plugin.json',
'{"identifier":"sirsoft-payment","version":"1.0.0"}'
);
$fakeBase = storage_path('test_apply_preserve_mp_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
// base 활성 디렉토리 + _bundled 모두 있는 상태
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce'.DIRECTORY_SEPARATOR.'module.json',
'{"identifier":"sirsoft-ecommerce","version":"0.9.0"}'
);
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce'.DIRECTORY_SEPARATOR.'module.json',
'{"installed":true}'
);
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-payment');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-payment'.DIRECTORY_SEPARATOR.'plugin.json',
'{"identifier":"sirsoft-payment","version":"0.9.0"}'
);
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'sirsoft-payment');
File::put(
$fakeBase.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'sirsoft-payment'.DIRECTORY_SEPARATOR.'plugin.json',
'{"installed":true}'
);
try {
config([
'app.update.targets' => ['modules/_bundled', 'plugins/_bundled'],
'app.update.excludes' => [],
'app.update.protected_paths' => ['.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
$this->service->applyUpdate($sourcePath);
// 활성 모듈/플러그인 디렉토리 보존
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce'.DIRECTORY_SEPARATOR.'module.json',
'활성 modules/sirsoft-ecommerce 이 보존되어야 한다'
);
$this->assertFileExists(
$fakeBase.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.'sirsoft-payment'.DIRECTORY_SEPARATOR.'plugin.json',
'활성 plugins/sirsoft-payment 이 보존되어야 한다'
);
// _bundled sync 정상
$this->assertStringContainsString(
'"version":"1.0.0"',
File::get($fakeBase.DIRECTORY_SEPARATOR.'modules'.DIRECTORY_SEPARATOR.'_bundled'.DIRECTORY_SEPARATOR.'sirsoft-ecommerce'.DIRECTORY_SEPARATOR.'module.json')
);
} finally {
app()->setBasePath($originalBasePath);
}
}
/**
* 자동 발견 폴백이 protected_paths 와 user excludes 양쪽을 존중하는지 검증합니다.
*/
public function test_apply_update_auto_discovery_respects_protected_paths_and_excludes(): void
{
$sourcePath = storage_path('test_apply_protected_src_'.uniqid());
$this->tempDirs[] = $sourcePath;
File::ensureDirectoryExists($sourcePath);
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'unknown-new-dir');
File::put($sourcePath.DIRECTORY_SEPARATOR.'unknown-new-dir'.DIRECTORY_SEPARATOR.'a.txt', 'x');
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'.claude');
File::put($sourcePath.DIRECTORY_SEPARATOR.'.claude'.DIRECTORY_SEPARATOR.'leak.txt', 'should_not_copy');
File::ensureDirectoryExists($sourcePath.DIRECTORY_SEPARATOR.'excluded-by-user');
File::put($sourcePath.DIRECTORY_SEPARATOR.'excluded-by-user'.DIRECTORY_SEPARATOR.'a.txt', 'x');
$fakeBase = storage_path('test_apply_protected_base_'.uniqid());
$this->tempDirs[] = $fakeBase;
File::ensureDirectoryExists($fakeBase);
$originalBasePath = base_path();
app()->setBasePath($fakeBase);
try {
config([
'app.update.targets' => [],
'app.update.excludes' => ['excluded-by-user'],
'app.update.protected_paths' => ['.claude', '.env', 'storage', 'vendor', 'node_modules', '.git'],
]);
$this->service->applyUpdate($sourcePath);
// 자동 발견 통과
$this->assertFileExists($fakeBase.DIRECTORY_SEPARATOR.'unknown-new-dir'.DIRECTORY_SEPARATOR.'a.txt');
// protected_paths 차단
$this->assertDirectoryDoesNotExist($fakeBase.DIRECTORY_SEPARATOR.'.claude');
// user excludes 차단
$this->assertDirectoryDoesNotExist($fakeBase.DIRECTORY_SEPARATOR.'excluded-by-user');
} finally {
app()->setBasePath($originalBasePath);
}
}
}