set('projectsend.edition', Edition::Community); $this->admin = User::factory()->create(); }); /** * @return array */ 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', 11)); $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()); });