Files
projectsend/tests/Feature/Platform/SchedulerMonitoringTest.php
T
ignacionelson f937b4398d Tell a missing file apart from a missing scanner, and do something about it
A row whose bytes are gone was recorded as "the scanner could not be
reached". Wrong on screen, and wrong underneath: that is the one reason
the hourly sweep re-queues, so every orphaned row would have been
rescanned hourly forever.

It is its own state now, `missing`, and withheld rather than offered:
a client who sees a file listed and gets an error on the download is
worse off than one who never saw it. Staff still see it, marked, which
is the point — somebody has to decide what to do about it. The refusal
says what it is ("no longer on the server") instead of sending somebody
looking for a permission that would let them through.

A daily `projectsend:check-missing-files` finds them, whether or not
this installation scans for viruses: it is not a virus question, and an
installation with no scanner has exactly the same problem. It compares
one disk listing against the rows rather than asking "does this exist?"
per file, which on object storage would be a request per file per day.
Files that come back — a remount, a restored backup — are picked up on
the next run and re-checked rather than left for dead.

They are listed beside the orphans, which is the same fault seen from
the other end: bytes with no row, rows with no bytes. The tab carries
the count, each row says where the file should be, and removing one
takes the record with it through the deletion that already exists.

The dashboard says how many there are, and so does
`projectsend:status`, because a fleet-wide jump in this is a storage
fault nothing else in that document would show.
2026-09-16 22:46:21 -03:00

225 lines
9.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
use App\Modules\Platform\Scheduling\TaskRunStatus;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
config()->set('projectsend.edition', Edition::Community);
$this->admin = User::factory()->create();
});
/**
* @return array<string, mixed>
*/
function schedulerPageProps(TestResponse $response): array
{
return json_decode(json_encode($response->viewData('page')), true)['props'];
}
test('the scheduler page lists every known command, flagging ones that have never run', function () {
ScheduledTaskRun::query()->create([
'command' => 'projectsend:purge-expired-files',
'status' => TaskRunStatus::Success,
'message' => null,
'duration_ms' => 42,
'ran_at' => now(),
]);
ScheduledTaskRun::query()->create([
'command' => 'projectsend:fetch-news',
'status' => TaskRunStatus::Failed,
'message' => 'Scheduled command [...] failed with exit code [1].',
'duration_ms' => null,
'ran_at' => now(),
]);
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 13));
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')
->and($tasks->get('projectsend:purge-expired-files')['duration_ms'])->toBe(42)
->and($tasks->get('projectsend:fetch-news')['status'])->toBe('failed')
->and($tasks->get('projectsend:fetch-news')['message'])->toContain('exit code [1]')
->and($tasks->get('projectsend:purge-orphan-files')['status'])->toBeNull();
});
function makeFailedJob(string $uuid, string $connection = 'database'): void
{
DB::table('failed_jobs')->insert([
'uuid' => $uuid,
'connection' => $connection,
'queue' => 'default',
'payload' => json_encode(['displayName' => 'App\\Fake']),
'exception' => "RuntimeException: boom\n#0 stack trace line",
'failed_at' => now(),
]);
}
test('failed jobs are listed with a pending-jobs count', function () {
$uuid = (string) Str::uuid();
makeFailedJob($uuid);
DB::table('jobs')->insert([
'queue' => 'default',
'payload' => json_encode(['displayName' => 'App\\Fake']),
'attempts' => 0,
'available_at' => now()->timestamp,
'created_at' => now()->timestamp,
]);
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$props = schedulerPageProps($response);
expect($props['pending_jobs_count'])->toBe(1)
->and($props['failed_jobs'])->toHaveCount(1)
->and($props['failed_jobs'][0]['id'])->toBe($uuid)
->and($props['failed_jobs'][0]['exception'])->toBe('RuntimeException: boom');
});
test('delete forgets a failed job', function () {
$uuid = (string) Str::uuid();
makeFailedJob($uuid);
$this->actingAs($this->admin)->delete("/system/settings/scheduler/failed-jobs/{$uuid}")->assertRedirect();
expect(DB::table('failed_jobs')->where('uuid', $uuid)->exists())->toBeFalse();
});
test('the page opens on the tasks tab, and ?tab=failed selects the failed-jobs tab', function () {
$this->actingAs($this->admin)->get('/system/settings/scheduler')
->assertInertia(fn (AssertableInertia $page) => $page->where('tab', 'tasks'));
$this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed')
->assertInertia(fn (AssertableInertia $page) => $page->where('tab', 'failed'));
});
test('failed jobs are paginated', function () {
for ($i = 0; $i < 25; $i++) {
makeFailedJob((string) Str::uuid());
}
$first = schedulerPageProps($this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed'));
expect($first['failed_jobs'])->toHaveCount(20)
->and($first['failed_total'])->toBe(25)
->and($first['failed_pagination']['last_page'])->toBe(2)
->and($first['failed_pagination']['page'])->toBe(1);
$second = schedulerPageProps($this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed&page=2'));
expect($second['failed_jobs'])->toHaveCount(5)
->and($second['failed_pagination']['page'])->toBe(2);
});
test('a page past the end redirects to the failed tab last page', function () {
makeFailedJob((string) Str::uuid());
$this->actingAs($this->admin)->get('/system/settings/scheduler?tab=failed&page=9')
->assertRedirect('/system/settings/scheduler?tab=failed');
});
test('delete all forgets every failed job', function () {
makeFailedJob((string) Str::uuid());
makeFailedJob((string) Str::uuid());
makeFailedJob((string) Str::uuid());
$this->actingAs($this->admin)->delete('/system/settings/scheduler/failed-jobs')->assertRedirect();
expect(DB::table('failed_jobs')->count())->toBe(0);
});
test('retry re-queues the job onto its original connection and forgets the failed row', function () {
// The 'database' connection (config/queue.php) writes back into the
// 'jobs' table via the app's normal DB connection — safe to exercise
// for real in tests, unlike 'redis', which needs a live server.
$uuid = (string) Str::uuid();
makeFailedJob($uuid, 'database');
$this->actingAs($this->admin)->post("/system/settings/scheduler/failed-jobs/{$uuid}/retry")->assertRedirect();
expect(DB::table('failed_jobs')->where('uuid', $uuid)->exists())->toBeFalse()
->and(DB::table('jobs')->count())->toBe(1);
});
test('the whole surface is unavailable on the cloud edition', function () {
config()->set('projectsend.edition', Edition::Cloud);
$this->actingAs($this->admin)->get('/system/settings/scheduler')->assertNotFound();
});
test('clients cannot access the scheduler page', function () {
$this->admin; // setup complete
$this->actingAs(User::factory()->client()->create())
->get('/system/settings/scheduler')
->assertRedirect(route('dashboard'));
});
test('staff without edit_settings cannot access the scheduler page', function () {
$role = Role::query()->create(['name' => 'No Settings', 'is_administrator' => false, 'is_system' => false]);
$staffer = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($staffer)->get('/system/settings/scheduler')->assertForbidden();
});
test('the update check reports what it found, not just that it ran', function () {
config()->set('projectsend.version', '2.0.0');
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String());
$settings->set(Setting::LatestKnownVersion, '2.5.0');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toContain('2.5.0');
});
test('a check that found nothing newer says so', function () {
config()->set('projectsend.version', '2.0.0');
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, now()->toIso8601String());
$settings->set(Setting::LatestKnownVersion, '2.0.0');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toBe('Up to date');
});
test('a check that has never run has nothing to report', function () {
$settings = app(Settings::class);
$settings->set(Setting::LatestVersionCheckedAt, '');
$settings->set(Setting::LatestKnownVersion, '');
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:check-for-updates')['detail'])->toBeNull();
});
// The list on the screen and the schedule are two hand-maintained lists of
// the same thing, and they had already drifted once: the API request log
// purge ran nightly without ever appearing here, so a failure of it was
// invisible on the screen built to make failures visible.
test('every scheduled command appears on the screen', function () {
$scheduled = collect(app(Schedule::class)->events())
->map(fn ($event): string => (string) Str::of((string) $event->command)->match('/projectsend:[\w-]+/'))
->filter()
->sort()
->values();
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$listed = collect(schedulerPageProps($response)['tasks'])->pluck('command')->sort()->values();
expect($listed->all())->toBe($scheduled->all());
});