Files
projectsend/tests/Feature/Platform/UpdateCommandTest.php
T
Ignacio Nelson dad8d21dc8 Translate the update strings into all sixteen locales (#1632)
The English for the updater, the stale-code banner and the activity-log
entry had settled, so this is their pass: 208 lines across sixteen
catalogues, every :placeholder intact, no existing entry reordered.

The activity row and its filter label were modelled on each catalogue's
own "Installed ProjectSend" / "ProjectSend was installed" pair, so the
new rows read like the ones beside them rather than like a different
translator wrote them.

One code change belongs to this pass rather than to the feature: the
update entry recorded "an unrecorded version" when the previous version
was unknown, and context values are substituted into the template
verbatim — never translated — so that sentence fragment would have sat
inside an otherwise Japanese or Polish row forever. It is a dash now.

Verified on a real install with the interface switched to Spanish:
"Sistema — Actualizó ProjectSend a 2.0.3, desde 2.0.2", and the action
filter reading "ProjectSend fue actualizado a una versión nueva".
Scan reports 0 missing in every locale; the Locale tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:13:17 -03:00

245 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\UpdateInstallation;
use Illuminate\Console\OutputStyle;
/**
* The ordering constraints inside UpdateInstallation are invisible in its
* result and expensive when wrong — a queue:restart before a cache clear
* leaves a worker on old code indefinitely, and config:cache breaks
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
* only thing that can assert them, so the artisan call is a seam.
*/
class RecordingUpdate extends UpdateInstallation
{
/** @var list<string> */
public array $calls = [];
/** @var array<string, int> */
public array $exitCodes = [];
/** @var array{route: bool, event: bool, config: bool} */
public array $warm = ['route' => false, 'event' => false, 'config' => false];
/** The test database is always migrated, so this cannot be observed for real. */
public bool $existingInstall = true;
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
$this->calls[] = $command;
return $this->exitCodes[$command] ?? 0;
}
protected function warmCaches(): array
{
return $this->warm;
}
protected function hasRunMigrationsBefore(): bool
{
return $this->existingInstall;
}
}
/**
* @param array{route?: bool, event?: bool, config?: bool} $warm
* @param array<string, int> $exitCodes
*/
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
{
$fake = new RecordingUpdate(
app(Illuminate\Contracts\Foundation\Application::class),
app(App\Modules\Identity\Permissions\EnsureSystemRoles::class),
app(Settings::class),
app(App\Modules\Audit\ActivityLogger::class),
);
$fake->warm = [...$fake->warm, ...$warm];
$fake->exitCodes = $exitCodes;
app()->instance(UpdateInstallation::class, $fake);
return $fake;
}
test('it migrates, ensures roles, links storage and restarts the queue', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->toContain('migrate', 'storage:link', 'queue:restart')
->and(array_search('migrate', $fake->calls, true))->toBe(0);
});
// queue:restart writes its signal into the cache. Anything that clears the
// cache afterwards deletes it, and the worker keeps running the old code
// with nothing to show for it.
test('queue:restart is the last thing it does', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect(array_key_last($fake->calls))->toBe(array_search('queue:restart', $fake->calls, true));
});
// config:cache stops TRUSTED_PROXIES from being read at all — see
// INSTALL.md. Nothing in an update may ever put one in place.
test('it never caches the configuration', function () {
$fake = recordingUpdate(['config' => true]);
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->toContain('config:clear')
->and($fake->calls)->not->toContain('config:cache');
});
// Laravel's Redis cache store implements cache:clear as FLUSHDB, which on
// a single-database Redis takes the sessions and the queue with it.
test('it does not flush the application cache', function () {
$fake = recordingUpdate();
$this->artisan('projectsend:update')->assertSuccessful();
expect($fake->calls)->not->toContain('cache:clear')
->and($fake->calls)->not->toContain('optimize:clear');
});
test('it rebuilds only the caches that were in place beforehand', function (array $warm, array $expected) {
$fake = recordingUpdate($warm);
$this->artisan('projectsend:update')->assertSuccessful();
$rebuilt = array_values(array_filter($fake->calls, fn (string $call): bool => str_ends_with($call, ':cache')));
expect($rebuilt)->toBe($expected);
})->with([
'nothing cached — a container, or an install that never optimised' => [[], []],
'routes only' => [['route' => true], ['route:cache', 'event:cache', 'view:cache']],
'events only' => [['event' => true], ['route:cache', 'event:cache', 'view:cache']],
'both' => [['route' => true, 'event' => true], ['route:cache', 'event:cache', 'view:cache']],
]);
test('a failed migration stops everything and records nothing', function () {
$fake = recordingUpdate([], ['migrate' => 1]);
$this->artisan('projectsend:update')->assertFailed();
expect($fake->calls)->toBe(['migrate'])
->and(app(Settings::class)->get(Setting::AppliedVersion))->toBe('');
});
// A route file that will not compile leaves a slower site. Failing the
// update over it would leave a broken one.
test('a cache that will not rebuild is a warning, not a failure', function () {
recordingUpdate(['route' => true], ['route:cache' => 1]);
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});
test('it records the version it applied', function () {
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'))
->and(app(Settings::class)->get(Setting::AppliedVersionAt))->not->toBe('');
});
// The real thing, not the seam: both entrypoints run this on every boot,
// so a second run has to be as uneventful as the first.
test('running it twice is uneventful', function () {
$this->artisan('projectsend:update')->assertSuccessful();
$this->artisan('projectsend:update')->assertSuccessful();
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});
// Not through the seam: this is the one assertion that the real wiring
// runs, and EnsureSystemRoles is the part of an update that a migration
// cannot do for itself. AccountManager rather than Uploader — the latter
// is legacy and deliberately never seeded.
test('it puts back a system role somebody deleted', function () {
Role::query()->where('name', SystemRole::AccountManager->value)->delete();
$this->artisan('projectsend:update')->assertSuccessful();
expect(Role::query()->where('name', SystemRole::AccountManager->value)->exists())->toBeTrue();
});
// The two shell files are the one place the sequence could quietly grow a
// second definition again, and nothing else in the suite would notice.
test('both container entrypoints run the command rather than their own sequence', function () {
foreach (['docker/production/entrypoint.sh', 'docker/app/entrypoint.sh'] as $entrypoint) {
$contents = file_get_contents(base_path($entrypoint));
expect($contents)->toContain('projectsend:update')
->and($contents)->not->toContain('projectsend:ensure-roles')
->and($contents)->not->toContain('migrate --force');
}
});
// An update is exactly the kind of thing the activity log exists for: it is
// the one place an administrator looks to find out what changed on this
// installation and when.
test('it writes the update to the activity log, naming both versions', function () {
app(Settings::class)->set(Setting::AppliedVersion, '2.0.0');
config()->set('projectsend.version', '2.1.0');
$this->artisan('projectsend:update')->assertSuccessful();
$entry = ActivityLog::query()->where('action', Action::ApplicationUpdated)->sole();
expect($entry->context['from'])->toBe('2.0.0')
->and($entry->context['to'])->toBe('2.1.0')
// Nobody's account did this — a scheduled boot or a shell did.
->and($entry->actor_id)->toBeNull();
});
// The container entrypoint runs this on every start. One log entry per
// restart would bury everything else in it.
test('re-running on the same version logs nothing', function () {
app(Settings::class)->set(Setting::AppliedVersion, config('projectsend.version'));
$this->artisan('projectsend:update')->assertSuccessful();
$this->artisan('projectsend:update')->assertSuccessful();
expect(ActivityLog::query()->where('action', Action::ApplicationUpdated)->count())->toBe(0);
});
// The first update of any installation older than this command finds no
// recorded version — and that update is precisely the one worth logging.
test('an update from a version that was never recorded is still logged', function () {
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe('');
$this->artisan('projectsend:update')->assertSuccessful();
$entry = ActivityLog::query()->where('action', Action::ApplicationUpdated)->sole();
// A dash, not a sentence: context values are substituted verbatim and
// never translated, so anything English here would outlive the locale.
expect($entry->context['from'])->toBe('—')
->and($entry->context['to'])->toBe(config('projectsend.version'));
});
// A first boot is an installation, not an update — SetupCompleted already
// records that, and the container runs this before anybody has installed
// anything.
test('a fresh installation logs no update', function () {
$fake = recordingUpdate();
$fake->existingInstall = false;
$this->artisan('projectsend:update')->assertSuccessful();
expect(ActivityLog::query()->where('action', Action::ApplicationUpdated)->count())->toBe(0)
->and(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});