state(); expect($state['agent_seen'])->toBeFalse() ->and($state['behind'])->toBeNull() ->and($state['available'])->toBeFalse(); Livewire::actingAs(operator('Owner')) ->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(); Livewire::actingAs(operator('Owner')) ->test(AdminSettings::class) ->assertSee(__('admin_settings.update_unknown')) ->assertDontSee(__('admin_settings.update_current')); }); 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')) ->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')) ->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')) ->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')) ->test(AdminSettings::class) ->assertOk(); }); function writeStatus(array $status): void { File::ensureDirectoryExists(storage_path('app/deploy')); File::put(storage_path('app/deploy/update-status.json'), json_encode($status)); } 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')) ->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 every five minutes. 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(); });