*/ public array $calls = []; /** @var array */ public array $exitCodes = []; /** @var array{route: bool, event: bool, config: bool} */ public array $warm = ['route' => false, 'event' => false, 'config' => false]; 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; } } /** * @param array{route?: bool, event?: bool, config?: bool} $warm * @param array $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), ); $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'); } });