Files
projectsend/tests/Feature/Platform/StaleCodeNoticeTest.php
T
denkfabrik-li 4469648d82 Stop the update tests emptying bootstrap/cache for every other worker
`UpdateWelcomeTest > staff who may not read system information are not
interrupted` fails on a parallel run roughly one time in six, with

    BindingResolutionException: Target [Inertia\Ssr\Gateway] is not
    instantiable

in a file that has nothing to do with updates. Run alone it is green
every time. The cause is not in that file.

`clear-compiled` deletes bootstrap/cache/packages.php and
bootstrap/cache/services.php. There is one of each for the whole
checkout, and `pest --parallel` gives eight worker processes the same
one. Instrumented over three full runs, the real command ran 12 times per
run -- 11 from UpdateCommandTest, 1 from StaleCodeNoticeTest -- and the
other workers observed the package manifest missing at boot 46 times.

What that costs is in PackageManifest::getManifest():

    if (! is_file($this->manifestPath)) {
        $this->build();
    }

    return $this->manifest = is_file($this->manifestPath) ?
        $this->files->getRequire($this->manifestPath) : [];

A worker that loses the second is_file() to another worker's unlink gets
`[]`: no discovered packages, so no package service providers, so
Inertia's is never registered and `Inertia\Ssr\Gateway` is never bound.
The next page it renders dies in the compiled root view, where
`@inertia` resolves that interface. Any test in any file, whichever one
happened to be booting.

Both halves measured. Building the manifest with inertia-laravel in
`dont-discover` reproduces the reported failure exactly -- same test,
same exception, same frame (`app('Inertia\Ssr\Gateway')` from the
compiled app.blade.php). And 12 real `clear-compiled` calls per run is
the count above.

UpdateCommandTest already owns a double for this, and says why in its own
docblock: the artisan call is a seam. Nine of its tests and one in
StaleCodeNoticeTest simply do not use it. None of them asserts that a
command ran -- they assert EnsureSystemRoles, the settings writes, the
activity log and the welcome marker, and the double touches none of
those. So the seam now covers the file, through a beforeEach rather than
per test, because the next test added here should not have to know any of
this.

The double moves to tests/Support and its helper to tests/Helpers.php,
for the reason that file documents: Pest hands whole files to workers, so
a class declared in one test file does not exist for another.

Not changed: UpdateInstallation. `clear-compiled` belongs in a real
update. Also not changed: giving each worker its own bootstrap/cache
through APP_PACKAGES_CACHE and friends. That would make the destruction
cheap rather than remove it, and nothing in the suite needs those
commands to run at all.

One new test, on the files rather than on the recorded call list -- a
future double that forgot to intercept one command would still satisfy a
call-list assertion. Counter-checked: with the beforeEach removed it goes
red on both manifests being gone (1 failed / 22 passed).

Eight consecutive parallel runs green after the change; the manifests'
mtimes are untouched by a full run, where before they were rewritten
every time. Full suite passes (2049 passed / 2 skipped). PHPStan level 8
clean -- it analyses `app` only, so it does not cover this change.

Pre-existing and left alone: pint reports `ordered_imports` on
UpdateCommandTest.php. Its import block is misordered on main too.
2026-08-28 00:32:57 +02:00

144 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Platform\Installation\InstallationKind;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* There is no way to be in a container and not in one within a single test
* run, so the detection is answered for — same seam and same reasoning as
* InstallationKindTest's own fake.
*/
class NoticeInstallation extends Installation
{
public function __construct(
private readonly bool $container,
private readonly bool $source = false,
) {}
protected function inContainer(): bool
{
return $this->container;
}
protected function builtFromSource(): bool
{
return $this->source;
}
}
function applied(string $version): void
{
app(Settings::class)->set(Setting::AppliedVersion, $version);
app(Settings::class)->set(Setting::AppliedVersionAt, now()->toIso8601String());
}
/**
* The shared prop as the page would receive it. Read out of the root
* view's data rather than through assertInertia's callback, because most
* of these assert the notice's *absence* — and a callback that never runs
* proves nothing.
*
* @return array<string, string>|null
*/
function noticeFor(User $user): ?array
{
$page = test()->actingAs($user)
->get('/dashboard')
->assertSuccessful()
->viewData('page');
return $page['props']['code_notice'] ?? null;
}
beforeEach(function () {
$this->admin = User::factory()->create();
config()->set('projectsend.version', '2.1.0');
});
test('nothing is said when the applied version is the running one', function () {
applied('2.1.0');
expect(noticeFor($this->admin))->toBeNull();
});
test('nothing is said on an installation that has never applied an update', function () {
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe('')
->and(noticeFor($this->admin))->toBeNull();
});
// The failure this exists for: files replaced, PHP never reloaded, so the
// database is ahead of the code every visitor is being served.
test('an applied version ahead of the running code reports stale code', function () {
applied('2.2.0');
$notice = noticeFor($this->admin);
expect($notice)->not->toBeNull()
->and($notice['reason'])->toBe('stale_code')
->and($notice['applied'])->toBe('2.2.0')
->and($notice['running'])->toBe('2.1.0');
});
// The other half of the same mistake: new files unpacked, the update never
// run, so the schema is behind the code.
test('an applied version behind the running code reports a pending update', function () {
applied('2.0.0');
expect(noticeFor($this->admin)['reason'])->toBe('pending_update');
});
test('it is gated on view_system_info', function () {
applied('2.2.0');
$uploader = User::factory()->create([
'role_id' => Role::query()->where('name', SystemRole::AccountManager->value)->value('id'),
]);
expect(noticeFor($uploader))->toBeNull()
->and(noticeFor($this->admin))->not->toBeNull();
})->skip(fn (): bool => Role::query()->where('name', SystemRole::AccountManager->value)->doesntExist(), 'needs the seeded roles');
// Unlike update_notice, which is edition-gated: what code a server is
// executing is a fact about the machine, not a feature of an edition.
test('it is not gated on the edition', function () {
config()->set('projectsend.edition', Edition::Cloud);
applied('2.2.0');
expect(noticeFor($this->admin))->not->toBeNull();
});
test('it names the command this kind of installation can actually run', function (bool $container, bool $source, string $expected) {
app()->instance(Installation::class, new NoticeInstallation($container, $source));
applied('2.2.0');
expect(noticeFor($this->admin)['install_kind'])->toBe($expected);
})->with([
'a container from the published image' => [true, false, InstallationKind::Container->value],
'a container built from a checkout' => [true, true, InstallationKind::ContainerSource->value],
'a server somebody administers' => [false, false, InstallationKind::Manual->value],
]);
// The rollback story, asserted end to end: whatever the marker said, the
// command rewrites it to whatever is actually running. Through the artisan
// seam, as everything that runs this command has to be — see
// Tests\Support\RecordingUpdate. The settings write this asserts is the
// real one.
test('running the update clears the notice', function () {
recordingUpdate();
applied('2.2.0');
expect(noticeFor($this->admin))->not->toBeNull();
$this->artisan('projectsend:update')->assertSuccessful();
expect(noticeFor($this->admin))->toBeNull();
});