bool, 'worker_id'=>string|null, 'reason'=>string] * - refreshWorkerHeartbeat(string $workerId): bool — 자기 worker_id 일 때만 true * - releaseWorkerLock(string $workerId): void * * 본 테스트는 헬퍼 자체의 결정론적 동작만 검증. 실제 두 PHP 프로세스 동시 실행은 * 매뉴얼 시나리오 검증 영역. */ class WorkerLockGuardTest extends TestCase { private string $tempBase = ''; private string $stateFile = ''; private string $skipReason = ''; protected function setUp(): void { parent::setUp(); // 안전 가드: 본 테스트는 BASE_PATH/storage/installer-state.json 을 write/unlink 한다. // BASE_PATH 는 PHP 상수라 한 프로세스에서 단 한 번만 정의되며, 다른 Installer 테스트가 // 먼저 프로젝트 루트로 박으면 본 테스트가 실제 운영 installer-state.json 을 파괴하게 된다. // BASE_PATH 가 시스템 temp 하위가 아니면 skip 한다. $tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir(); if (defined('BASE_PATH')) { $resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH; if (strpos($resolved, $tempPrefix) !== 0) { $this->skipReason = 'BASE_PATH (' . $resolved . ') 가 시스템 temp 하위가 아님 — ' . '다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 실제 installer-state.json 파괴 방지를 위해 skip. ' . '격리 실행: php vendor/bin/phpunit --filter=WorkerLockGuardTest'; $this->markTestSkipped($this->skipReason); } $this->tempBase = (string) BASE_PATH; } else { $this->tempBase = sys_get_temp_dir() . '/g7-worker-lock-test-' . bin2hex(random_bytes(4)); define('BASE_PATH', $this->tempBase); } if (! is_dir($this->tempBase . '/storage')) { @mkdir($this->tempBase . '/storage', 0755, true); } $this->stateFile = BASE_PATH . '/storage/installer-state.json'; @file_put_contents($this->stateFile, json_encode(['installation_status' => 'pending'])); require_once dirname(__DIR__, 3) . '/public/install/includes/config.php'; require_once dirname(__DIR__, 3) . '/public/install/includes/installer-state.php'; } protected function tearDown(): void { // skip 상태(= BASE_PATH 가 실 루트)면 파괴적 정리를 절대 수행하지 않는다. if ($this->skipReason === '' && $this->stateFile !== '') { @unlink($this->stateFile); } parent::tearDown(); } public function test_acquire_succeeds_when_no_active_worker(): void { $result = acquireWorkerLock(15); $this->assertTrue($result['acquired']); $this->assertNotEmpty($result['worker_id']); $this->assertSame('available', $result['reason']); } public function test_acquire_persists_worker_id_and_heartbeat_in_state(): void { $result = acquireWorkerLock(15); $state = json_decode(file_get_contents($this->stateFile), true); $this->assertSame($result['worker_id'], $state['active_worker_id']); $this->assertIsInt($state['last_heartbeat']); $this->assertGreaterThanOrEqual(time() - 1, $state['last_heartbeat']); } public function test_acquire_fails_when_active_worker_within_stale_window(): void { $first = acquireWorkerLock(15); $this->assertTrue($first['acquired']); // 두 번째 워커 진입 시도 — 활동 중이라 거부되어야 함 $second = acquireWorkerLock(15); $this->assertFalse($second['acquired']); $this->assertNull($second['worker_id']); $this->assertSame('busy', $second['reason']); // state 의 worker_id 는 첫 번째 그대로 $state = json_decode(file_get_contents($this->stateFile), true); $this->assertSame($first['worker_id'], $state['active_worker_id']); } public function test_acquire_takes_over_when_heartbeat_stale(): void { $first = acquireWorkerLock(15); // heartbeat 를 stale 시점으로 강제 변경 $state = json_decode(file_get_contents($this->stateFile), true); $state['last_heartbeat'] = time() - 60; file_put_contents($this->stateFile, json_encode($state)); $second = acquireWorkerLock(15); $this->assertTrue($second['acquired']); $this->assertNotSame($first['worker_id'], $second['worker_id']); $this->assertSame('takeover_stale', $second['reason']); $newState = json_decode(file_get_contents($this->stateFile), true); $this->assertSame($second['worker_id'], $newState['active_worker_id']); } public function test_refresh_heartbeat_returns_true_for_owner(): void { $first = acquireWorkerLock(15); // 의도적 1초 지연 (heartbeat 갱신 detection) sleep(1); $ok = refreshWorkerHeartbeat($first['worker_id']); $this->assertTrue($ok); $state = json_decode(file_get_contents($this->stateFile), true); $this->assertGreaterThanOrEqual(time() - 1, $state['last_heartbeat']); } public function test_refresh_heartbeat_returns_false_for_non_owner(): void { $first = acquireWorkerLock(15); // 다른 worker_id 가 takeover 한 상황 시뮬레이션 $state = json_decode(file_get_contents($this->stateFile), true); $state['active_worker_id'] = 'someone-else'; file_put_contents($this->stateFile, json_encode($state)); $ok = refreshWorkerHeartbeat($first['worker_id']); $this->assertFalse($ok); } public function test_release_clears_worker_id_and_heartbeat(): void { $first = acquireWorkerLock(15); releaseWorkerLock($first['worker_id']); $state = json_decode(file_get_contents($this->stateFile), true); $this->assertArrayNotHasKey('active_worker_id', $state); $this->assertArrayNotHasKey('last_heartbeat', $state); } public function test_release_no_op_when_worker_id_does_not_match(): void { $first = acquireWorkerLock(15); // 다른 worker 가 takeover $state = json_decode(file_get_contents($this->stateFile), true); $newWorkerId = 'taker-' . bin2hex(random_bytes(4)); $state['active_worker_id'] = $newWorkerId; file_put_contents($this->stateFile, json_encode($state)); // 원본 worker 가 release 시도 — 자기 worker_id 가 아니므로 무시 releaseWorkerLock($first['worker_id']); $stateAfter = json_decode(file_get_contents($this->stateFile), true); $this->assertSame($newWorkerId, $stateAfter['active_worker_id']); } public function test_acquire_after_release_succeeds(): void { $first = acquireWorkerLock(15); releaseWorkerLock($first['worker_id']); $second = acquireWorkerLock(15); $this->assertTrue($second['acquired']); $this->assertSame('available', $second['reason']); $this->assertNotSame($first['worker_id'], $second['worker_id']); } }