state(); expect($state['agent_seen'])->toBeFalse() ->and($state['behind'])->toBeNull() ->and($state['available'])->toBeFalse(); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_no_agent')); }); it('never reports "up to date" when it simply does not know', function () { // Null and zero are different answers. Showing the first as the second is // how an installation sits three versions behind looking healthy. expect(app(UpdateChannel::class)->state()['available'])->toBeFalse(); // Asserted on the state, not on the markup: "Aktuell" is also a substring // of "Aktuelles Passwort" elsewhere on the same page. Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) ->assertViewHas('update', fn (array $u) => $u['behind'] === null && $u['available'] === false); }); it('reports an available update once the agent has looked', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 3]); $state = app(UpdateChannel::class)->state(); expect($state['available'])->toBeTrue()->and($state['behind'])->toBe(3); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_available', ['n' => 3])); }); it('leaves a request the agent can find', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->call('requestUpdate') ->assertHasNoErrors(); $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); expect($request['requested_by'])->toBeString()->not->toBeEmpty() ->and($request['requested_at'])->toBeString(); }); it('does not queue a second run when the button is pressed twice', function () { $channel = app(UpdateChannel::class); expect($channel->request('someone@example.com'))->toBeTrue() // Impatience, not intent — and two overlapping runs of update.sh fight // over the checkout. ->and($channel->request('someone@example.com'))->toBeFalse(); }); it('forgets a request nobody ever collected', function () { // Written while the agent was down. Firing it days later, whenever the // agent happens to come back, is not what anyone asked for. File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-request.json'), json_encode([ 'requested_at' => Carbon::now()->subHours(3)->toIso8601String(), 'requested_by' => 'someone@example.com', ])); $channel = app(UpdateChannel::class); expect($channel->pendingRequest())->toBeNull() ->and($channel->state()['running'])->toBeFalse() // And a fresh request is accepted again rather than blocked forever. ->and($channel->request('someone@example.com'))->toBeTrue(); }); it('needs the capability, not merely an operator account', function () { Livewire::actingAs(operator('Support'), 'operator') ->test(AdminSettings::class) ->call('requestUpdate') ->assertForbidden(); }); it('survives a status file that is corrupt', function () { // This is the page an operator opens when something is already wrong. It // must not be the second thing that is broken. File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-status.json'), '{ this is not json'); $state = app(UpdateChannel::class)->state(); expect($state['agent_seen'])->toBeFalse(); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertOk(); }); it('carries a live countdown target for a queued update, not a server-formatted clock time', function () { // The agent checks on a timer, so pressing the button visibly does // nothing until the next tick. Silence there reads as a broken button — // reported exactly that way — so the card now counts down live in the // browser (resources/js/app.js) instead of naming a clock time. // // Frozen: the assertion is about the arithmetic, and a clock that moves // between writing the file and reading it turns 1 into 0.998. Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); writeStatus([ 'state' => 'idle', 'checked_at' => now()->toIso8601String(), 'check_interval_minutes' => 1, 'behind' => 1, ]); app(UpdateChannel::class)->request('owner@example.com'); $state = app(UpdateChannel::class)->state(); expect($state['running'])->toBeTrue() ->and($state['next_check_at']?->toIso8601String())->toBe(now()->addMinutes(1)->toIso8601String()); // The raw instant, not a clock time formatted server-side: there is no // local()-vs-utc conversion left here for R19 to catch a regression of — // the browser owns the countdown and localises it itself by construction // (new Date() on an ISO string with an explicit offset). What matters is // that the RIGHT instant reaches the page. Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_starts_in')) ->assertSee($state['next_check_at']->toIso8601String(), escape: false); }); it('names the step the deployment is on, in the operator’s language', function () { // The step comes from the file update.sh rewrites as it goes, NOT from the // status document: the agent writes that once and then blocks for the whole // run, so a phase taken from there is frozen at the first step forever. writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'started_at' => now()->subMinutes(2)->toIso8601String(), 'behind' => 1, ]); writePhase('migrate'); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_step', [ 'step' => __('admin_settings.update_phase.migrate'), ])); }); it('follows the step file as the deployment moves on', function () { writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); writePhase('composer'); expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.composer')); writePhase('restart'); expect(app(UpdateChannel::class)->state()['phase'])->toBe(__('admin_settings.update_phase.restart')); }); it('does not print a raw phase key it has no translation for', function () { // A newer deployment script against older translations. Better to say // nothing than to put `update_phase.something` in front of an operator. writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1, ]); writePhase('something_new'); expect(app(UpdateChannel::class)->state()['phase'])->toBeNull(); }); it('does not present the last failure’s step as this update’s progress', function () { // A failed run leaves its phase file in place on purpose — that is how the // failing step gets named. Queueing the next update must not then show it // as already mid-migration, hiding the only thing that is actually known: // when it will start. Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); writeStatus([ 'state' => 'idle', 'checked_at' => now()->toIso8601String(), 'check_interval_minutes' => 5, 'behind' => 1, ]); writePhase('migrate'); app(UpdateChannel::class)->request('owner@example.com'); $state = app(UpdateChannel::class)->state(); expect($state['running'])->toBeTrue() ->and($state['phase'])->toBeNull(); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_starts_in')) ->assertDontSee(__('admin_settings.update_phase.migrate')); }); it('ignores a step written before the run that is going on now', function () { // A manual `bash deploy/update.sh` on the shell leaves a phase file the // agent never cleared. Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); File::ensureDirectoryExists(storage_path('app/deploy')); File::put( storage_path('app/deploy/update-phase'), "assets\t".now()->subHour()->toIso8601String()."\n", ); writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'started_at' => now()->subMinute()->toIso8601String(), 'behind' => 1, ]); expect(app(UpdateChannel::class)->state()['phase'])->toBeNull(); }); it('never promises a start time that has already gone by', function () { // A systemd timer that ran late leaves the last check older than its own // interval. checked_at + interval is then in the past, and the page would // promise a start that demonstrably did not happen. Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00')); writeStatus([ 'state' => 'idle', 'checked_at' => now()->subMinutes(12)->toIso8601String(), 'check_interval_minutes' => 5, 'behind' => 1, ]); app(UpdateChannel::class)->request('owner@example.com'); $next = app(UpdateChannel::class)->state()['next_check_at']; expect($next)->not->toBeNull() ->and($next->isFuture())->toBeTrue() // One interval from now is the only honest upper bound once the timer // is already overdue. ->and($next->toIso8601String())->toBe(now()->addMinutes(5)->toIso8601String()); }); it('names the step a failed update stopped at', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); writeLastRun([ 'state' => 'failed', 'started_at' => now()->subMinutes(3)->toIso8601String(), 'finished_at' => now()->toIso8601String(), 'phase' => 'migrate', 'error' => 'update_failed', 'exit_code' => 1, ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_failed_at', [ 'step' => __('admin_settings.update_phase.migrate'), ])); }); function writeStatus(array $status): void { File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-status.json'), json_encode($status)); } /** What deploy/update.sh writes as it moves from step to step. */ function writePhase(string $key): void { File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-phase'), $key."\t".now()->toIso8601String()."\n"); } function writeLastRun(array $run): void { File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-last-run.json'), json_encode($run)); } it('stops trusting an agent that has gone quiet', function () { // A status file written once by an agent since stopped would otherwise // leave the button enabled forever, writing requests nobody collects. writeStatus(['state' => 'idle', 'checked_at' => now()->subHour()->toIso8601String(), 'behind' => 2]); $state = app(UpdateChannel::class)->state(); expect($state['agent_seen'])->toBeFalse() ->and($state['behind'])->toBeNull() ->and($state['available'])->toBeFalse(); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_no_agent')); }); it('shows the agent’s failure in the operator’s language', function () { // The agent is a shell script and is not translated; it reports a code. writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 3]); expect(app(UpdateChannel::class)->state()['last_error']) ->toBe(__('admin_settings.update_error.update_failed', ['code' => '3'])); // An unknown code is passed through rather than swallowed: a message // nobody has worded yet still beats silence while an update is failing. writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'something_new']); expect(app(UpdateChannel::class)->state()['last_error'])->toBe('something_new'); }); it('still shows a failed update after the next routine check', function () { // The check runs on a timer. Writing both into one file meant a failure // was usually gone before anyone looked at it. writeLastRun(['state' => 'failed', 'finished_at' => now()->toIso8601String(), 'error' => 'update_failed', 'exit_code' => 2]); writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); $state = app(UpdateChannel::class)->state(); expect($state['last_state'])->toBe('failed') ->and($state['last_error'])->toBe(__('admin_settings.update_error.update_failed', ['code' => '2'])); }); it('refuses a second request while the agent is mid-run', function () { // The agent deletes the request before it starts, so between that moment // and the end of the run there is nothing "pending" to block on. writeStatus(['state' => 'running', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); $channel = app(UpdateChannel::class); expect($channel->isRunning())->toBeTrue() ->and($channel->request('someone@example.com'))->toBeFalse(); expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); }); /** * "Nach Aktualisierungen suchen" — check only, never apply. * * The request carries a `kind` so the agent can tell the two apart. These * cover the panel's half: a check must never read as "running" (no * maintenance mode, no full-screen overlay for a question that was only "is * there something new"), and the one request slot is still shared, so * pressing either button while the other is outstanding is refused the same * way pressing the same one twice already was. */ it('writes a distinct request kind for a check, not a flag on the existing one', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->call('requestCheck') ->assertHasNoErrors(); $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); expect($request['kind'])->toBe('check') ->and($request['requested_by'])->toBeString()->not->toBeEmpty(); }); it('does not treat a pending check as "running" — no maintenance overlay for a question', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); $channel = app(UpdateChannel::class); expect($channel->requestCheck('owner@example.com'))->toBeTrue(); $state = $channel->state(); expect($state['checking'])->toBeTrue() ->and($state['running'])->toBeFalse(); }); it('does treat a pending run as "running", not "checking"', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); $channel = app(UpdateChannel::class); expect($channel->request('owner@example.com'))->toBeTrue(); $state = $channel->state(); expect($state['running'])->toBeTrue() ->and($state['checking'])->toBeFalse(); }); it('does not queue a second check when the button is pressed twice', function () { $channel = app(UpdateChannel::class); expect($channel->requestCheck('someone@example.com'))->toBeTrue() ->and($channel->requestCheck('someone@example.com'))->toBeFalse(); }); it('blocks a check while a run is pending, and a run while a check is pending', function () { $channel = app(UpdateChannel::class); expect($channel->request('someone@example.com'))->toBeTrue() ->and($channel->requestCheck('someone@example.com'))->toBeFalse(); File::delete(storage_path('app/deploy/update-request.json')); expect($channel->requestCheck('someone@example.com'))->toBeTrue() ->and($channel->request('someone@example.com'))->toBeFalse(); }); it('needs the capability to check, not merely an operator account', function () { Livewire::actingAs(operator('Support'), 'operator') ->test(AdminSettings::class) ->call('requestCheck') ->assertForbidden(); }); it('confirms a check was requested, worded for a check rather than an update', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->call('requestCheck') ->assertDispatched('notify', message: __('admin_settings.update_check_requested')); }); it('offers "Jetzt aktualisieren" even when the last check found nothing', function () { // Reported as "I cannot run an update, it says everything is current". // `behind` is a READING taken up to a minute ago, not the state of the // world: push a commit and the console insisted it was up to date and // refused to act on it. The agent fetches before deciding, so asking // against a stale reading costs one fetch — being locked out costs the // deployment. writeStatus([ 'checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0, ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_now')) ->call('requestUpdate'); expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull(); }); it('offers both actions together whenever the agent is reachable and idle', function () { // Two separate buttons now, not one whose label changed with the // reading: looking and applying are different intentions. writeStatus([ 'checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0, ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_check')) ->assertSee(__('admin_settings.update_now')); }); it('still refuses when there is no agent to pick the request up', function () { // The one case where either button really would go nowhere: nothing is // running that would ever read the request file. writeStatus([ 'checked_at' => now()->subDay()->toIso8601String(), 'state' => 'idle', 'behind' => 0, ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) ->assertDontSee(__('admin_settings.update_check')) ->assertDontSee(__('admin_settings.update_now')); }); it('carries the watcher onto every console page, not only settings', function () { // wire:poll cannot see the run through to the end, because the run // restarts the containers answering the poll — so the watcher (and the // maintenance overlay it drives) lives in the layout, not this one page, // and has to be there however an operator reaches the console. $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.overview')) ->assertOk() ->assertSee('updateWatcher(', escape: false); }); it('binds the watcher exactly once when the settings page is open', function () { // The settings card used to bind its own copy as well as the layout now // doing so for every page — two pollers racing each other against the // same endpoint. writeStatus([ 'checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 2, ]); $html = $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.settings')) ->assertOk() ->getContent(); expect(substr_count($html, 'updateWatcher('))->toBe(1); }); it('answers the watcher without Livewire in the way', function () { writeStatus(['checked_at' => now()->toIso8601String(), 'state' => 'idle', 'behind' => 0]); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJsonStructure(['running', 'commit', 'behind', 'last_finished_at', 'step', 'next_check_at', 'running_since', 'log']) ->assertHeader('Cache-Control', 'no-store, private'); }); it('carries the current step in the watcher JSON, already in the operator’s language', function () { // The overlay only ever displays this string — it must arrive ready to // print, the same sentence the settings card itself renders. writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'started_at' => now()->subMinute()->toIso8601String(), 'behind' => 1, ]); writePhase('migrate'); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJson(['step' => __('admin_settings.update_step', [ 'step' => __('admin_settings.update_phase.migrate'), ])]); }); it('carries no step in the watcher JSON before the deployment has announced one', function () { writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 0]); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJson(['step' => null]); }); it('carries how long a run has been going in the watcher JSON, already worded', function () { // Ready to print, the same sentence update_since renders on the settings // card — the overlay never assembles this from a raw timestamp either. writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'started_at' => now()->subMinutes(2)->toIso8601String(), 'behind' => 1, ]); writePhase('migrate'); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJson(['running_since' => __('admin_settings.update_since', [ 'when' => now()->subMinutes(2)->diffForHumans(), ])]); }); it('carries the tail of the run log in the watcher JSON once a run has actually started', function () { writeStatus([ 'state' => 'running', 'checked_at' => now()->toIso8601String(), 'started_at' => now()->subMinute()->toIso8601String(), 'behind' => 1, ]); writePhase('migrate'); File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-last-run.log'), "line one\nline two\n"); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJson(['log' => "line one\nline two"]); }); it('carries no running-since or log while only queued, not yet actually running', function () { // Otherwise this would be the PREVIOUS run's leftovers, arriving under a // countdown that says the run has not started yet. writeStatus(['state' => 'idle', 'checked_at' => now()->toIso8601String(), 'behind' => 1]); File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-last-run.log'), "an earlier run\n"); app(UpdateChannel::class)->request('owner@example.com'); $this->actingAs(operator('Owner'), 'operator') ->get(route('admin.update.state')) ->assertOk() ->assertJson(['running_since' => null, 'log' => null]) ->assertJsonStructure(['next_check_at']); }); it('does not answer the watcher for somebody who is not an operator', function () { $this->actingAs(User::factory()->create()) ->get(route('admin.update.state')) ->assertForbidden(); });