fix(update): graceful Livewire-less /update-progress page — no more 502/whitescreen on update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
ffc17966ef
commit
d2f70900ff
|
|
@ -171,7 +171,7 @@ class Index extends Component
|
|||
* runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we
|
||||
* say so and do not await a result.
|
||||
*/
|
||||
public function requestUpdate(DeploymentService $deployment): void
|
||||
public function requestUpdate(DeploymentService $deployment): mixed
|
||||
{
|
||||
$channel = $this->channel();
|
||||
$installed = (string) config('clusev.version');
|
||||
|
|
@ -184,7 +184,7 @@ class Index extends Component
|
|||
$this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]);
|
||||
$this->dispatch('notify', message: __('versions.update_not_available'), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$this->updateState = 'update';
|
||||
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]);
|
||||
|
|
@ -193,7 +193,7 @@ class Index extends Component
|
|||
if (RateLimiter::tooManyAttempts($key, 3)) {
|
||||
$this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
RateLimiter::hit($key, 600);
|
||||
|
||||
|
|
@ -202,7 +202,7 @@ class Index extends Component
|
|||
if (! $deployment->requestUpdate()) {
|
||||
$this->dispatch('notify', message: __('versions.update_write_failed'), level: 'error');
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// Persist the in-progress state in Redis (NOT the consumed sentinel) so the "läuft" notice
|
||||
// survives a reload AND the app rebuild; auto-expires as a backstop. Completion = version
|
||||
|
|
@ -221,6 +221,20 @@ class Index extends Component
|
|||
$this->updateBaseVersion = $installed; // completion = the running version moves past this
|
||||
$this->updatePolls = 0;
|
||||
$this->dispatch('notify', message: __('versions.update_started'));
|
||||
|
||||
// Redirect to the self-contained progress page BEFORE the container goes down.
|
||||
// navigate:false forces a full browser navigation (the page must be Livewire-independent
|
||||
// to survive the teardown — see resources/views/update-progress.blade.php).
|
||||
$prev = url()->previous();
|
||||
$parsedPrev = parse_url($prev, PHP_URL_PATH);
|
||||
$returnPath = (is_string($parsedPrev) && str_starts_with($parsedPrev, '/') && ! str_starts_with($parsedPrev, '//'))
|
||||
? $parsedPrev
|
||||
: route('dashboard', absolute: false);
|
||||
|
||||
return $this->redirect(
|
||||
route('update.progress', ['return' => $returnPath]),
|
||||
navigate: false,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the configured channel, clamped to the user-facing set. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// Update-progress page strings (R16). Used by resources/views/update-progress.blade.php.
|
||||
return [
|
||||
'page_title' => 'Update läuft — Clusev',
|
||||
'heading' => 'Update läuft',
|
||||
'subtitle' => 'Der Stack wird neu gebaut. Diese Seite erkennt automatisch, wann er wieder erreichbar ist.',
|
||||
|
||||
// Phase labels (displayed in the checklist)
|
||||
'phase_fetch' => 'Holen',
|
||||
'phase_build' => 'Bauen',
|
||||
'phase_migrate' => 'Migrieren',
|
||||
'phase_restart' => 'Neustart',
|
||||
|
||||
// Status messages
|
||||
'elapsed' => 'Vergangene Zeit',
|
||||
'done_heading' => 'Fertig — wird neu geladen…',
|
||||
'timeout_heading' => 'Update dauert ungewöhnlich lange',
|
||||
'timeout_hint' => 'Bitte die Seite manuell neu laden oder die Logs prüfen (journalctl -u clusev-update.service).',
|
||||
'reload_button' => 'Neu laden',
|
||||
];
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// Update-progress page strings (R16). Used by resources/views/update-progress.blade.php.
|
||||
return [
|
||||
'page_title' => 'Update in progress — Clusev',
|
||||
'heading' => 'Update in progress',
|
||||
'subtitle' => 'The stack is rebuilding. This page will automatically detect when it is back up.',
|
||||
|
||||
// Phase labels (displayed in the checklist)
|
||||
'phase_fetch' => 'Fetch',
|
||||
'phase_build' => 'Build',
|
||||
'phase_migrate' => 'Migrate',
|
||||
'phase_restart' => 'Restart',
|
||||
|
||||
// Status messages
|
||||
'elapsed' => 'Elapsed time',
|
||||
'done_heading' => 'Done — reloading…',
|
||||
'timeout_heading' => 'Update is taking unusually long',
|
||||
'timeout_hint' => 'Please reload the page manually or check the logs (journalctl -u clusev-update.service).',
|
||||
'reload_button' => 'Reload',
|
||||
];
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
<!DOCTYPE html>
|
||||
{{--
|
||||
Self-contained update-progress page. NO Livewire, NO Alpine, NO external JS.
|
||||
Loaded into the browser BEFORE the container teardown triggered by the update
|
||||
request. While the backend is down the page is already client-side; its inline
|
||||
JS polling loop tolerates 502s and detects recovery when /up returns 200.
|
||||
Modelled on errors/layout.blade.php (same token-only CSS, same wordmark).
|
||||
--}}
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('update.page_title') }}</title>
|
||||
@include('partials.head-icons')
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="min-h-screen bg-void font-sans text-ink antialiased">
|
||||
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12 sm:px-6">
|
||||
<div class="w-full max-w-lg">
|
||||
|
||||
{{-- Wordmark --}}
|
||||
<div class="mb-10 flex items-center justify-center gap-2.5">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
</span>
|
||||
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
|
||||
</div>
|
||||
|
||||
{{-- Main card --}}
|
||||
<div class="rounded-xl border border-line bg-surface p-6 shadow-panel sm:p-8">
|
||||
|
||||
{{-- Spinner + heading --}}
|
||||
<div class="flex flex-col items-center gap-5 text-center">
|
||||
<div id="js-spinner" class="relative h-14 w-14" aria-hidden="true">
|
||||
<svg class="h-14 w-14 -rotate-90" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="28" cy="28" r="24" stroke-width="3" class="stroke-line"/>
|
||||
<circle id="js-spinner-arc" cx="28" cy="28" r="24" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="150.796"
|
||||
stroke-dashoffset="37.699"
|
||||
class="stroke-accent origin-center"
|
||||
style="animation: clusev-spin 1.4s linear infinite; transform-origin: 50% 50%;" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div id="js-status-running">
|
||||
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.heading') }}</h1>
|
||||
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div id="js-status-done" class="hidden">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-online" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.done_heading') }}</h1>
|
||||
</div>
|
||||
|
||||
<div id="js-status-timeout" class="hidden">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-warning" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
|
||||
<h1 class="font-display text-xl font-semibold text-warning">{{ __('update.timeout_heading') }}</h1>
|
||||
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.timeout_hint') }}</p>
|
||||
<button onclick="window.location.reload()" class="mt-4 inline-flex items-center gap-2 rounded-md border border-line bg-raised px-4 py-2 font-mono text-sm text-ink transition-colors hover:border-accent/40 hover:text-accent">
|
||||
{{ __('update.reload_button') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Phase checklist --}}
|
||||
<div class="mt-8">
|
||||
<ol class="space-y-3" aria-label="Update phases">
|
||||
@php
|
||||
$phases = [
|
||||
['key' => 'fetch', 'label' => __('update.phase_fetch')],
|
||||
['key' => 'build', 'label' => __('update.phase_build')],
|
||||
['key' => 'migrate', 'label' => __('update.phase_migrate')],
|
||||
['key' => 'restart', 'label' => __('update.phase_restart')],
|
||||
];
|
||||
@endphp
|
||||
@foreach ($phases as $i => $phase)
|
||||
<li class="flex items-center gap-3 rounded-lg border border-line bg-raised/50 px-4 py-3 transition-colors duration-500"
|
||||
id="js-phase-{{ $phase['key'] }}"
|
||||
data-phase-index="{{ $i }}">
|
||||
{{-- Dot --}}
|
||||
<span class="h-2 w-2 shrink-0 rounded-full bg-ink-4 transition-colors duration-500"
|
||||
id="js-phase-dot-{{ $phase['key'] }}"></span>
|
||||
{{-- Label --}}
|
||||
<span class="flex-1 font-mono text-sm text-ink-3 transition-colors duration-500"
|
||||
id="js-phase-label-{{ $phase['key'] }}">{{ $phase['label'] }}</span>
|
||||
{{-- Check icon (hidden until complete) --}}
|
||||
<svg id="js-phase-check-{{ $phase['key'] }}" class="h-4 w-4 shrink-0 text-online hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{{-- Elapsed time --}}
|
||||
<p class="mt-6 text-center font-mono text-[11px] text-ink-4" id="js-elapsed" aria-live="polite">
|
||||
{{ __('update.elapsed') }}: <span id="js-elapsed-value">0s</span>
|
||||
</p>
|
||||
|
||||
</div>{{-- /card --}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Inline CSS for the spinner animation (no Alpine/external deps) --}}
|
||||
<style>
|
||||
@keyframes clusev-spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
{{-- Inline JS — no external dependencies. Reads ?return= for the redirect target.
|
||||
Polls /up every 2500 ms; requires 2 consecutive 200s before redirecting (flap guard).
|
||||
Times out after 10 minutes and shows a manual-reload prompt. --}}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Return URL ─────────────────────────────────────────────────────────
|
||||
// Sanitise to a same-origin relative path: must start with a single /,
|
||||
// must NOT start with //, http, or contain backslashes.
|
||||
var raw = new URLSearchParams(window.location.search).get('return') || '';
|
||||
var returnUrl = '/';
|
||||
if (raw && /^\/[^\/\\]/.test(raw) && !/^\/\//g.test(raw) && !/^https?:/i.test(raw)) {
|
||||
returnUrl = raw;
|
||||
}
|
||||
|
||||
// ── Phase timing heuristic (purely visual) ─────────────────────────────
|
||||
// Approx durations (seconds) per phase before advancing the highlight.
|
||||
var PHASE_KEYS = ['fetch', 'build', 'migrate', 'restart'];
|
||||
var PHASE_TIMES = [15, 40, 60, 90]; // cumulative seconds to enter each phase
|
||||
var currentPhase = -1;
|
||||
|
||||
function setPhaseActive(index) {
|
||||
for (var i = 0; i < PHASE_KEYS.length; i++) {
|
||||
var key = PHASE_KEYS[i];
|
||||
var li = document.getElementById('js-phase-' + key);
|
||||
var dot = document.getElementById('js-phase-dot-' + key);
|
||||
var label = document.getElementById('js-phase-label-' + key);
|
||||
var check = document.getElementById('js-phase-check-' + key);
|
||||
if (!li) continue;
|
||||
if (i < index) {
|
||||
// completed
|
||||
li.classList.remove('border-accent/30', 'bg-accent/5');
|
||||
dot.style.backgroundColor = 'var(--color-online)';
|
||||
label.style.color = 'var(--color-online)';
|
||||
check.classList.remove('hidden');
|
||||
} else if (i === index) {
|
||||
// active
|
||||
li.classList.add('border-accent/30', 'bg-accent/5');
|
||||
dot.style.backgroundColor = 'var(--color-accent)';
|
||||
label.style.color = 'var(--color-ink)';
|
||||
check.classList.add('hidden');
|
||||
} else {
|
||||
// pending
|
||||
li.classList.remove('border-accent/30', 'bg-accent/5');
|
||||
dot.style.backgroundColor = '';
|
||||
label.style.color = '';
|
||||
check.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
currentPhase = index;
|
||||
}
|
||||
|
||||
setPhaseActive(0);
|
||||
|
||||
// ── Elapsed timer ──────────────────────────────────────────────────────
|
||||
var startTime = Date.now();
|
||||
var elapsedEl = document.getElementById('js-elapsed-value');
|
||||
var TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function formatElapsed(ms) {
|
||||
var s = Math.floor(ms / 1000);
|
||||
var m = Math.floor(s / 60);
|
||||
if (m > 0) return m + 'm ' + (s % 60) + 's';
|
||||
return s + 's';
|
||||
}
|
||||
|
||||
// ── DOM refs ───────────────────────────────────────────────────────────
|
||||
var spinnerEl = document.getElementById('js-spinner');
|
||||
var runningEl = document.getElementById('js-status-running');
|
||||
var doneEl = document.getElementById('js-status-done');
|
||||
var timeoutEl = document.getElementById('js-status-timeout');
|
||||
|
||||
// ── Polling ────────────────────────────────────────────────────────────
|
||||
var consecutiveOk = 0;
|
||||
var STABLE_REQUIRED = 2;
|
||||
var pollInterval = null;
|
||||
var tickInterval = null;
|
||||
var done = false;
|
||||
|
||||
function showDone() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(tickInterval);
|
||||
// Show all phases complete
|
||||
setPhaseActive(PHASE_KEYS.length);
|
||||
spinnerEl.classList.add('hidden');
|
||||
runningEl.classList.add('hidden');
|
||||
doneEl.classList.remove('hidden');
|
||||
setTimeout(function () {
|
||||
window.location.assign(returnUrl);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function showTimeout() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(tickInterval);
|
||||
spinnerEl.classList.add('hidden');
|
||||
runningEl.classList.add('hidden');
|
||||
timeoutEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function tick() {
|
||||
var elapsed = Date.now() - startTime;
|
||||
|
||||
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
|
||||
|
||||
// Advance phase highlight based on elapsed time
|
||||
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
|
||||
if (elapsed >= PHASE_TIMES[p] * 1000) {
|
||||
if (currentPhase !== p) setPhaseActive(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elapsed >= TIMEOUT_MS) {
|
||||
showTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (done) return;
|
||||
fetch('/up', { cache: 'no-store', method: 'GET' })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
consecutiveOk++;
|
||||
if (consecutiveOk >= STABLE_REQUIRED) showDone();
|
||||
} else {
|
||||
consecutiveOk = 0;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
// 502 / network error while the container is rebuilding — keep waiting
|
||||
consecutiveOk = 0;
|
||||
});
|
||||
}
|
||||
|
||||
tickInterval = setInterval(tick, 1000);
|
||||
tick(); // immediate first tick
|
||||
|
||||
// Small initial delay before the first poll — the teardown starts right after
|
||||
// the browser receives the redirect, so we give it a moment to go down before
|
||||
// we start checking if it's back up (avoids a false-positive on the old instance).
|
||||
setTimeout(function () {
|
||||
poll(); // first poll
|
||||
pollInterval = setInterval(poll, 2500);
|
||||
}, 3000);
|
||||
|
||||
}());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -107,5 +107,9 @@ Route::middleware('auth')->group(function () {
|
|||
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||
Route::get('/system', System\Index::class)->name('system');
|
||||
Route::get('/help', Help\Index::class)->name('help');
|
||||
|
||||
// Plain Blade view — deliberately NOT a Livewire component so it survives the
|
||||
// container teardown that follows an update request (R1/R2 exception, by design).
|
||||
Route::get('/update-progress', fn () => view('update-progress'))->name('update.progress');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue