CluPilotCloud/tests/Feature/Admin/UpdateButtonTest.php

764 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Livewire\Admin\Settings as AdminSettings;
use App\Models\User;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Updating the installation from the console.
*
* The panel cannot run the update — it is www-data inside the container the
* update restarts. It leaves a request for the host-side agent instead. These
* tests cover the panel's half: what it reports, what it refuses, and what it
* must never claim.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
});
it('does not offer to update when the agent has never reported in', function () {
// Without an agent the button would do nothing at all, and a button that
// does nothing is worse than a missing one.
$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('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('says a queued update is starting, and never counts down to it', function () {
// There was a live countdown here to the agent's next tick. It ran
// backwards a few seconds and snapped back to a full minute, over and
// over, and it did so on every server whose systemd timer is longer than
// the interval the agent reports — the target was recomputed on each poll
// and had already gone by, so the "honest upper bound" fallback moved it
// forward again every time. Reported exactly that way.
//
// Nothing counts down now. The agent is woken when the request lands.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 1,
'behind' => 1,
'request_watch' => true,
]);
app(UpdateChannel::class)->request('owner@example.com');
$state = app(UpdateChannel::class)->state();
expect($state['running'])->toBeTrue()
->and($state)->not->toHaveKey('next_check_at');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starting'));
});
it('names the step the deployment is on, in the operators 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 failures step as this updates 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_starting_queued'))
->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('promises no start time at all, on the exact configuration that used to loop', function () {
// The reported case, reproduced: the agent reports a one-minute interval
// (the value in the repo) while the host's systemd timer is still five, so
// checked_at + interval is in the past for four minutes out of every five.
// The old code answered "now + interval" afresh on every poll, which is a
// countdown that resets forever instead of reaching zero.
//
// There is nothing left to get wrong: no time is promised anywhere.
Carbon::setTestNow(Carbon::parse('2026-07-27 12:34:00'));
writeStatus([
'state' => 'idle',
'checked_at' => now()->subMinutes(4)->toIso8601String(),
'check_interval_minutes' => 1,
'behind' => 1,
]);
app(UpdateChannel::class)->request('owner@example.com');
expect(app(UpdateChannel::class)->state())->not->toHaveKey('next_check_at');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// No clock, and — the part that actually broke — no element for a
// browser-side timer to keep resetting.
->assertDontSee('x-data="countdown', escape: false);
});
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 agents failure in the operators 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'));
});
/**
* A check is a question, and a question does not get a countdown.
*
* Reported verbatim: "ich suche nur nach neuen Updates, was ist das da für ein
* Timer, den ich nicht gestartet habe?" The check had been routed through the
* same timer as a real update, so the card showed the only thing it had —
* "Startet in 3:44" — for an operator who had asked for nothing to start.
*
* The agent is now woken the moment the request lands (the path unit in
* deploy/install-agent.sh) and reports whether that trigger exists, because a
* server that has not re-run the installer still waits for the timer and must
* not be told otherwise.
*/
it('shows a pending check its badge and nothing else — no time, no clock', function () {
// Even on a host with no path unit, where the answer really does wait for
// the timer. A check starts nothing, so there is nothing to announce; the
// badge saying it is looking is the whole of what can honestly be said.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 0,
'request_watch' => false,
]);
app(UpdateChannel::class)->requestCheck('owner@example.com');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_checking'))
->assertDontSee(__('admin_settings.update_starting'))
->assertDontSee(__('admin_settings.update_starting_queued'));
});
it('tells a queued update apart from an immediate one, without either naming a time', function () {
// The one thing 'instant' still decides: whether the console may say the
// update starts NOW, or has to admit it starts when the agent gets to it.
// Neither answer contains a number.
writeStatus([
'state' => 'idle',
'checked_at' => now()->toIso8601String(),
'check_interval_minutes' => 5,
'behind' => 1,
'request_watch' => false,
]);
app(UpdateChannel::class)->request('owner@example.com');
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_starting_queued'))
->assertDontSee(__('admin_settings.update_starting'));
});
it('does not believe a stale agent about being woken on demand', function () {
// The field says what was installed when the agent last ran. From an agent
// that has since stopped it is not a promise anybody can keep.
writeStatus([
'state' => 'idle',
'checked_at' => now()->subHour()->toIso8601String(),
'behind' => 0,
'request_watch' => true,
]);
expect(app(UpdateChannel::class)->state()['instant'])->toBeFalse();
});
it('keeps the panel, the agent and the installer in step about the on-demand trigger', function () {
// Three files, one contract: the installer creates the path unit, the agent
// reports whether it is active, the panel decides from that whether it may
// say "now". If they drift, the console promises an instant answer on a
// server that still waits five minutes for it — which is the exact failure
// this replaced.
$installer = File::get(base_path('deploy/install-agent.sh'));
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($installer)->toContain('/etc/systemd/system/clupilot-update-agent.path')
->and($installer)->toContain('PathChanged=')
->and($installer)->toContain('systemctl enable --now clupilot-update-agent.path')
->and($agent)->toContain('is-active --quiet clupilot-update-agent.path')
->and($agent)->toContain('"request_watch"');
});
/**
* An update is a released version, never the head of a branch.
*
* The console used to offer "Jetzt aktualisieren" unconditionally, because
* `behind` counted commits on main and was a reading a minute old — refusing on
* a stale reading cost the deployment. That whole problem belonged to following
* a branch. Nothing is installed now unless a tag exists whose version is
* higher than the one deployed, and pressing the check button answers within a
* second, so a stale reading is no longer something to design around.
*/
it('refuses to queue an update when nothing newer has been released', function () {
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 0,
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_only_releases'))
->call('requestUpdate')
->assertDispatched('notify', message: __('admin_settings.update_nothing_released'));
// Not merely a disabled button: a Livewire action is a public endpoint, and
// a queued run for nothing costs a maintenance window.
expect(app(UpdateChannel::class)->pendingRequest())->toBeNull();
});
it('offers the update, by version, once a release above the installed one exists', function () {
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 1,
'target_release' => 'v1.1.0',
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
// The number somebody can decide about, not "1 Aktualisierung".
->assertSee(__('admin_settings.update_available_release', ['version' => '1.1.0']))
->assertDontSee(__('admin_settings.update_only_releases'))
->call('requestUpdate')
->assertDispatched('notify', message: __('admin_settings.update_requested'));
expect(app(UpdateChannel::class)->pendingRequest())->not->toBeNull();
});
it('does not name a release the agent never reported', function () {
// An older agent writes no target_release. The count is all there is, and
// inventing a version number from it would be worse than the count.
writeStatus([
'checked_at' => now()->toIso8601String(),
'state' => 'idle',
'behind' => 2,
]);
expect(app(UpdateChannel::class)->state()['target_release'])->toBeNull();
Livewire::actingAs(operator('Owner'), 'operator')
->test(AdminSettings::class)
->assertSee(__('admin_settings.update_available', ['n' => 2]));
});
it('decides what is newer by version and installs only a tag', function () {
// The agent's half of the same rule, which no PHP test can reach: it asks
// one question regardless of what the checkout is attached to, compares by
// version rather than by branch position, and hands update.sh a RELEASE
// rather than a BRANCH.
$agent = File::get(base_path('deploy/update-agent.sh'));
expect($agent)->toContain('version_gt')
->and($agent)->toContain('sort -V')
->and($agent)->toContain('RELEASE="$TARGET_RELEASE"')
// No branch left to fall back onto, which is what made a commit on main
// installable in the first place.
->and($agent)->not->toContain('BRANCH="$BRANCH" "$ROOT/deploy/update.sh"');
});
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', 'running_since', 'log'])
->assertHeader('Cache-Control', 'no-store, private');
});
it('carries the current step in the watcher JSON, already in the operators 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, presented as the
// progress of a run that has not begun.
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])
// Nothing for the overlay to count down to, either.
->assertJsonMissing(['next_check_at' => null]);
});
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();
});