set('clusev.repository', self::REPO); app(DeploymentService::class)->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(); } /** The cache key ReleaseChecker uses for the configured repo (host-discriminated). */ private function tagKey(string $channel = 'stable'): string { return 'clusev:latest-release:'.substr(md5(self::REPO), 0, 8).':'.$channel; } 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_shows_the_available_versions_changelog_before_updating(): 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']]), 'gitea.test/api/v1/repos/o/clusev/raw/CHANGELOG.md*' => Http::response( "# Changelog\n\n## [0.9.6] - 2026-06-22\n\n### Hinzugefügt\n- Brandneues Feature XYZ.\n\n". "## [0.9.5] - 2026-06-22\n\n### Behoben\n- Fehler ABC behoben.\n\n". "## [0.9.4] - 2026-06-21\n\n### Geändert\n- Schon installiert.\n" ), ]); Livewire::test(Index::class) ->call('checkUpdates') ->assertSet('updateState', 'update') ->assertViewHas('availableUpdates', fn ($u) => count($u) === 2) // 0.9.6 + 0.9.5 only ->assertSee('Brandneues Feature XYZ') ->assertSee('Fehler ABC behoben') ->assertDontSee('Schon installiert'); // 0.9.4 == installed → excluded // Tags are published as "vX.Y.Z" — the CHANGELOG.md must be fetched at the v-prefixed ref, // not the bare version, or it 404s on a real host and the preview silently stays empty. Http::assertSent(fn ($req) => ! str_contains($req->url(), 'raw/CHANGELOG.md') || str_contains($req->url(), 'ref=v0.9.6')); } public function test_no_whats_new_preview_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('checkUpdates') ->assertViewHas('availableUpdates', []); } 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_passive_latest_release_never_shows_older_than_installed(): void { // A stale cache from before an update must not render a "latest" below the installed // version (which would look like the panel is behind a release it has already passed). config()->set('clusev.version', '0.9.10'); Cache::put($this->tagKey(), '0.9.9', now()->addMinutes(30)); Livewire::test(Index::class)->assertViewHas('latestTag', null); } public function test_auto_check_on_load_surfaces_an_update_without_a_manual_click(): void { config()->set('clusev.version', '0.9.10'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.13']])]); Livewire::test(Index::class) ->call('autoCheck') ->assertSet('updateState', 'update') ->assertSee('0.9.13'); } public function test_auto_check_does_not_run_while_an_update_is_in_progress(): void { Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.13']])]); Livewire::test(Index::class) ->set('updateRequested', true) ->call('autoCheck'); Http::assertNothingSent(); // guarded — never clobbers the running state } public function test_update_available_is_shown_on_load_without_clicking_check(): void { // The cached check (badge already knew) surfaces the update + button on mount, no re-click. config()->set('clusev.version', '0.9.10'); Cache::put($this->tagKey(), '0.9.11', now()->addMinutes(30)); Livewire::test(Index::class) ->assertSet('updateState', 'update') ->assertSee('0.9.11'); } public function test_an_in_progress_marker_resumes_the_running_state_on_load(): void { // Redis marker (not the consumed sentinel) is what survives a reload mid-update. config()->set('clusev.version', '0.9.11'); Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15)); Livewire::test(Index::class)->assertSet('updateRequested', true); } public function test_a_finished_update_is_announced_and_cleared_on_load(): void { // Reloaded after the rebuild: the running version is now past the marked base → done. config()->set('clusev.version', '0.9.12'); Cache::put('clusev:update-in-progress', ['base' => '0.9.11'], now()->addMinutes(15)); Livewire::test(Index::class) ->assertSet('updateRequested', false) ->assertSet('updateState', 'current') ->assertSee('0.9.12'); $this->assertNull(Cache::get('clusev:update-in-progress'), 'marker cleared once the update landed'); } public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void { config()->set('clusev.version', '0.9.10'); $c = Livewire::test(Index::class) ->set('updateRequested', true) ->set('updateBaseVersion', '0.9.10'); // Still the old version mid-rebuild → keeps waiting. $c->call('pollUpdate')->assertSet('updateRequested', true); // Rebuilt container now reports a newer version → done + back to "current". config()->set('clusev.version', '0.9.11'); $c->call('pollUpdate') ->assertSet('updateRequested', false) ->assertSet('updateState', 'current') ->assertSee('0.9.11'); } 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_update_surfaces_an_error_when_the_sentinel_cannot_be_written(): void { config()->set('clusev.version', '0.9.10'); Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.14']])]); $mock = \Mockery::mock(DeploymentService::class)->makePartial(); $mock->shouldReceive('requestUpdate')->andReturn(false); // unwritable sentinel dir $this->app->instance(DeploymentService::class, $mock); Livewire::test(Index::class) ->call('requestUpdate') ->assertSet('updateRequested', false); $this->assertNull(Cache::get('clusev:update-in-progress'), 'no in-progress marker when the sentinel write failed'); } 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', ); } // ── Project links: dev lists source + public; everyone else only public ────── /** @param array $repos */ private static function urls(array $repos): array { return array_column($repos, 'url'); } public function test_dev_box_lists_its_source_and_the_public_repo(): void { config()->set('clusev.release_controls', true); // the dev / release-control box config()->set('clusev.public_slug', 'clusev/clusev'); Http::fake(); // setUp() set clusev.repository to the (Gitea-style) source — the dev box lists it FIRST, // then the public repo. Both visible. Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ self::REPO, 'https://github.com/clusev/clusev', ]); } public function test_dev_box_drops_a_garbage_source_url(): void { config()->set('clusev.release_controls', true); config()->set('clusev.repository', 'not-a-url'); // misconfigured source (no host) config()->set('clusev.public_slug', 'clusev/clusev'); Http::fake(); // A garbage source is dropped (would render an empty link) — the public repo still shows. Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ 'https://github.com/clusev/clusev', ]); } public function test_non_dev_install_lists_only_the_public_repo(): void { config()->set('clusev.release_controls', false); // staging / stable config()->set('clusev.public_slug', 'clusev/clusev'); Http::fake(); // Even though clusev.repository points at the private Gitea source (setUp), the card lists // ONLY the public repo — a non-dev server never exposes the private source. Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ 'https://github.com/clusev/clusev', ]); } public function test_non_dev_public_link_follows_the_public_slug(): void { config()->set('clusev.release_controls', false); config()->set('clusev.public_slug', 'acme/pub'); Http::fake(); Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [ 'https://github.com/acme/pub', ]); } /** * A malformed public_slug must never produce a private/garbage link — it falls back to the * canonical public repo. Covers empty, surrounding slashes, and a slug mistakenly set to a full * (possibly private) URL. */ #[DataProvider('malformedSlugs')] public function test_non_dev_public_link_falls_back_for_a_malformed_slug(string $slug, string $expected): void { config()->set('clusev.release_controls', false); config()->set('clusev.public_slug', $slug); Http::fake(); Livewire::test(Index::class)->assertViewHas('repositories', fn ($repos) => self::urls($repos) === [$expected]); } /** @return array */ public static function malformedSlugs(): array { return [ 'empty' => ['', 'https://github.com/clusev/clusev'], 'surrounding slashes' => ['/acme/pub/', 'https://github.com/acme/pub'], 'full private url' => ['https://gitea.internal/o/repo', 'https://github.com/clusev/clusev'], ]; } }