707 lines
28 KiB
PHP
707 lines
28 KiB
PHP
<?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 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_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 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'));
|
||
});
|
||
|
||
/**
|
||
* 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"');
|
||
});
|
||
|
||
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', '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, 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();
|
||
});
|