793 lines
32 KiB
PHP
793 lines
32 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"');
|
||
});
|
||
|
||
/**
|
||
* 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 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();
|
||
});
|
||
|
||
it('shows one design while updating, not two swapping mid-run', function () {
|
||
// Reported as "the first screen must not be there". It was the console's
|
||
// own overlay in the app's light tokens; then the containers went down and
|
||
// the 503 page took over with a completely different face. Two designs for
|
||
// one event, replacing each other mid-update.
|
||
//
|
||
// Both render the same partial now, so there is nothing left that can
|
||
// disagree.
|
||
$console = Illuminate\Support\Facades\File::get(resource_path('views/layouts/admin.blade.php'));
|
||
$maintenance = Illuminate\Support\Facades\File::get(resource_path('views/errors/503.blade.php'));
|
||
|
||
expect($console)->toContain("partials.updating-panel")
|
||
->and($maintenance)->toContain("partials.updating-panel")
|
||
// And nothing left behind that would render a second look.
|
||
->and($console)->not->toContain('update_overlay_title')
|
||
->and($maintenance)->not->toContain('<style>');
|
||
});
|
||
|
||
it('keeps the deployment step in the overlay, where an operator needs it', function () {
|
||
// The panel is shared with a page customers see, so the operator detail is
|
||
// behind a flag rather than always present — but it has to still be there,
|
||
// because "läuft gerade" on its own is what sends somebody to the shell.
|
||
$panel = Illuminate\Support\Facades\File::get(resource_path('views/partials/updating-panel.blade.php'));
|
||
|
||
expect($panel)->toContain('x-text="step"')
|
||
->toContain('x-text="runningSince"')
|
||
->toContain('x-text="log"');
|
||
});
|