clusev/tests/Feature/UpdateProgressTest.php

197 lines
7.0 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Versions\Index;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Livewire;
use Tests\TestCase;
/**
* Verifies the graceful update-progress redirect flow:
* (a) requestUpdate() still writes the sentinel, sets the in-progress Redis flag,
* respects the throttle, AND now redirects to route('update.progress').
* (b) GET /update-progress returns 200, contains the heading, and is Livewire-free.
*/
class UpdateProgressTest extends TestCase
{
use RefreshDatabase;
private const TAGS_URL = 'git.bave.dev/api/v1/repos/boban/clusev/tags*';
protected function setUp(): void
{
parent::setUp();
Cache::flush();
app(DeploymentService::class)->clearUpdateRequest();
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
protected function tearDown(): void
{
app(DeploymentService::class)->clearUpdateRequest();
parent::tearDown();
}
// ── (a) requestUpdate() behaviour ─────────────────────────────────────────
public function test_request_update_redirects_to_progress_page(): void
{
config()->set('clusev.version', '0.9.4');
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
// The redirect includes a ?return= query param; assertRedirectContains checks the path.
$response = Livewire::test(Index::class)->call('requestUpdate');
$redirectUrl = $response->effects['redirect'] ?? '';
$this->assertStringContainsString(
route('update.progress', absolute: false),
$redirectUrl,
'requestUpdate must redirect to the update-progress page',
);
}
public function test_request_update_still_writes_sentinel_and_sets_redis_flag(): 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');
$this->assertTrue(
app(DeploymentService::class)->updateRequested(),
'the update sentinel a host watcher reacts to must still be written',
);
$marker = Cache::get('clusev:update-in-progress');
$this->assertIsArray($marker, 'the in-progress Redis flag must be set');
$this->assertSame('0.9.4', $marker['base'], 'the base version must be captured in the flag');
}
public function test_request_update_still_audits_the_action(): 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');
$this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists());
}
public function test_request_update_still_respects_throttle(): 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);
}
// Throttled — must NOT redirect, must NOT write sentinel.
Livewire::test(Index::class)
->call('requestUpdate')
->assertSet('updateRequested', false);
$this->assertFalse(
app(DeploymentService::class)->updateRequested(),
'throttle must block the sentinel write',
);
$this->assertNull(Cache::get('clusev:update-in-progress'), 'no Redis flag when throttled');
}
public function test_request_update_does_not_redirect_when_nothing_to_update(): 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)
->assertNoRedirect();
}
public function test_request_update_does_not_redirect_when_sentinel_not_writable(): void
{
config()->set('clusev.version', '0.9.4');
Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]);
$mock = \Mockery::mock(DeploymentService::class)->makePartial();
$mock->shouldReceive('requestUpdate')->andReturn(false);
$this->app->instance(DeploymentService::class, $mock);
Livewire::test(Index::class)
->call('requestUpdate')
->assertSet('updateRequested', false)
->assertNoRedirect();
}
// ── (b) /update-progress page ─────────────────────────────────────────────
public function test_update_progress_page_returns_200_for_authenticated_user(): void
{
$this->get(route('update.progress'))->assertOk();
}
public function test_update_progress_page_redirects_guests_to_login(): void
{
auth()->logout();
$this->get(route('update.progress'))->assertRedirect(route('login'));
}
public function test_update_progress_page_contains_heading(): void
{
$this->get(route('update.progress'))
->assertOk()
->assertSee('Update');
}
public function test_update_progress_page_has_no_livewire_scripts(): void
{
$body = $this->get(route('update.progress'))->content();
$this->assertStringNotContainsStringIgnoringCase(
'livewire',
$body,
'the update-progress page must contain no Livewire script tags — it must survive the backend going down',
);
}
public function test_update_progress_page_has_no_wire_attributes(): void
{
$body = $this->get(route('update.progress'))->content();
$this->assertStringNotContainsString(
'wire:',
$body,
'the update-progress page must contain no wire: attributes',
);
}
public function test_update_progress_page_polls_up_endpoint_in_its_js(): void
{
$body = $this->get(route('update.progress'))->content();
$this->assertStringContainsString(
"fetch('/up'",
$body,
'the inline JS must poll /up to detect when the app comes back',
);
}
public function test_update_progress_page_contains_phase_labels(): void
{
$body = $this->get(route('update.progress'))->content();
// German labels (default locale in tests)
$this->assertStringContainsString('Holen', $body);
$this->assertStringContainsString('Bauen', $body);
$this->assertStringContainsString('Migrieren', $body);
$this->assertStringContainsString('Neustart', $body);
}
}