clearUpdateRequest(); // never inherit a stray sentinel $this->actingAs(User::factory()->create(['must_change_password' => false])); } protected function tearDown(): void { app(DeploymentService::class)->clearUpdateRequest(); // leave the storage dir clean parent::tearDown(); } public function test_reports_update_available_from_the_remote_tag_api(): void { config()->set('clusev.version', '0.9.4'); Http::fake([self::TAGS_URL => Http::response([ ['name' => 'v0.9.6'], ['name' => 'v0.9.5'], ['name' => 'v0.9.4'], ])]); Livewire::test(Index::class) ->call('checkUpdates') ->assertSet('updateState', 'update') ->assertSee('0.9.6'); } public function test_reports_current_when_installed_is_at_or_above_the_latest_tag(): void { config()->set('clusev.version', '0.9.9'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); Livewire::test(Index::class) ->call('checkUpdates') ->assertSet('updateState', 'current'); } public function test_stable_channel_ignores_prereleases_but_beta_sees_them(): void { config()->set('clusev.version', '0.9.6'); Http::fake([self::TAGS_URL => Http::response([ ['name' => 'v1.0.0-beta1'], ['name' => 'v0.9.6'], ])]); // stable: 1.0.0-beta1 is not offered, 0.9.6 == installed → current Setting::put('release_channel', 'stable'); Livewire::test(Index::class)->call('checkUpdates')->assertSet('updateState', 'current'); // beta: the prerelease is newer → update available Cache::flush(); Setting::put('release_channel', 'beta'); Livewire::test(Index::class) ->call('checkUpdates') ->assertSet('updateState', 'update') ->assertSee('1.0.0-beta1'); } public function test_degrades_gracefully_when_the_remote_api_is_unreachable(): void { // Installed version above any real local .git tag, so the local fallback can't claim an // update — this asserts a failed remote lookup never crashes the action. config()->set('clusev.version', '99.0.0'); Http::fake([self::TAGS_URL => Http::response('upstream down', 500)]); Livewire::test(Index::class) ->call('checkUpdates') ->assertSet('updateState', 'current') ->assertSet('lastChecked', now()->format('H:i')); } public function test_request_update_writes_the_sentinel_and_audits_when_available(): void { config()->set('clusev.version', '0.9.4'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); Livewire::test(Index::class) ->call('requestUpdate') ->assertSet('updateRequested', true); $this->assertTrue( app(DeploymentService::class)->updateRequested(), 'the update sentinel a host watcher reacts to must exist', ); $this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists()); } public function test_request_update_refuses_when_already_current(): void { config()->set('clusev.version', '0.9.9'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); Livewire::test(Index::class) ->call('requestUpdate') ->assertSet('updateRequested', false) ->assertSet('updateState', 'current'); $this->assertFalse( app(DeploymentService::class)->updateRequested(), 'no sentinel when there is nothing newer to apply', ); } public function test_build_info_prefers_the_baked_config_over_a_git_read(): void { // Production bakes sha/branch into config via .env (no .git in the image). config()->set('clusev.build.sha', 'abc1234'); config()->set('clusev.build.branch', 'feat/v1-foundation'); Livewire::test(Index::class) ->assertViewHas('build', ['sha' => 'abc1234', 'branch' => 'feat/v1-foundation']) ->assertSee('abc1234') ->assertSee('feat/v1-foundation'); } public function test_request_update_is_throttled_per_user(): void { config()->set('clusev.version', '0.9.4'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); $key = 'update-request:'.auth()->id(); for ($i = 0; $i < 3; $i++) { RateLimiter::hit($key, 600); } Livewire::test(Index::class) ->call('requestUpdate') ->assertSet('updateRequested', false); $this->assertFalse( app(DeploymentService::class)->updateRequested(), 'the per-user throttle must block the sentinel write', ); } }