coolify/tests/Feature/ScheduledJobMonitoringTest.php
Andras Bacsai 31555f9e8a fix(jobs): prevent non-due jobs firing on restart and enrich skip logs with resource links
- Refactor shouldRunNow() to only fire on first run (empty cache) if actually due by cron schedule, preventing spurious executions after cache loss or service restart
- Add enrichSkipLogsWithLinks() method to fetch and populate resource names and links for tasks, backups, and docker cleanup jobs in skip logs
- Update skip logs UI to display resource column with links to related resources, improving navigation and context
- Add fallback display when linked resources are deleted
- Expand tests to cover both restart scenarios: non-due jobs (should not fire) and due jobs (should fire)
2026-02-28 18:03:29 +01:00

272 lines
8.9 KiB
PHP

<?php
use App\Livewire\Settings\ScheduledJobs;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\SchedulerLogParser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create root team (id 0) and root user
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
$this->rootUser = User::factory()->create();
$this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']);
// Create regular team and user
$this->regularTeam = Team::factory()->create();
$this->regularUser = User::factory()->create();
$this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']);
});
test('scheduled jobs page requires instance admin access', function () {
$this->actingAs($this->regularUser);
session(['currentTeam' => $this->regularTeam]);
$response = $this->get(route('settings.scheduled-jobs'));
$response->assertRedirect(route('dashboard'));
});
test('scheduled jobs page is accessible by instance admin', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Scheduled Job Issues');
});
test('scheduled jobs page shows failed backup executions', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => 1,
'database_type' => 'App\Models\StandalonePostgresql',
'enabled' => true,
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'failed',
'message' => 'Backup failed: connection timeout',
]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Backup');
});
test('scheduled jobs page shows failed cleanup executions', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$server = Server::factory()->create([
'team_id' => $this->rootTeam->id,
]);
DockerCleanupExecution::create([
'server_id' => $server->id,
'status' => 'failed',
'message' => 'Cleanup failed: disk full',
]);
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('Cleanup');
});
test('filter by type works', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->set('filterType', 'backup')
->assertStatus(200)
->set('filterType', 'cleanup')
->assertStatus(200)
->set('filterType', 'task')
->assertStatus(200);
});
test('only failed executions are shown', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->rootTeam->id,
'frequency' => '0 * * * *',
'database_id' => 1,
'database_type' => 'App\Models\StandalonePostgresql',
'enabled' => true,
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'message' => 'Backup completed successfully',
]);
ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'failed',
'message' => 'Backup failed: connection refused',
]);
Livewire::test(ScheduledJobs::class)
->assertSee('Backup failed: connection refused')
->assertDontSee('Backup completed successfully');
});
test('filter by date range works', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
Livewire::test(ScheduledJobs::class)
->set('filterDate', 'last_7d')
->assertStatus(200)
->set('filterDate', 'last_30d')
->assertStatus(200)
->set('filterDate', 'all')
->assertStatus(200);
});
test('scheduler log parser returns empty collection when no logs exist', function () {
$parser = new SchedulerLogParser;
$skips = $parser->getRecentSkips();
expect($skips)->toBeEmpty();
$runs = $parser->getRecentRuns();
expect($runs)->toBeEmpty();
})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests');
test('scheduler log parser parses skip entries correctly', function () {
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}';
file_put_contents($logPath, $logLine."\n");
$parser = new SchedulerLogParser;
$skips = $parser->getRecentSkips();
expect($skips)->toHaveCount(1);
expect($skips->first()['type'])->toBe('backup');
expect($skips->first()['reason'])->toBe('server_not_functional');
expect($skips->first()['team_id'])->toBe(5);
// Cleanup
@unlink($logPath);
});
test('scheduler log parser excludes started events from runs', function () {
$logPath = storage_path('logs/scheduled-test-started-filter.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// Temporarily rename existing logs so they don't interfere
$existingLogs = glob(storage_path('logs/scheduled-*.log'));
$renamed = [];
foreach ($existingLogs as $log) {
$tmp = $log.'.bak';
rename($log, $tmp);
$renamed[$tmp] = $log;
}
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$lines = [
'['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}',
'['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}',
];
file_put_contents($logPath, implode("\n", $lines)."\n");
$parser = new SchedulerLogParser;
$runs = $parser->getRecentRuns();
expect($runs)->toHaveCount(1);
expect($runs->first()['message'])->toContain('completed');
// Cleanup
@unlink($logPath);
foreach ($renamed as $tmp => $original) {
rename($tmp, $original);
}
});
test('scheduler log parser filters by team id', function () {
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
$lines = [
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}',
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}',
];
file_put_contents($logPath, implode("\n", $lines)."\n");
$parser = new SchedulerLogParser;
$allSkips = $parser->getRecentSkips(100);
expect($allSkips)->toHaveCount(2);
$team1Skips = $parser->getRecentSkips(100, 1);
expect($team1Skips)->toHaveCount(1);
expect($team1Skips->first()['team_id'])->toBe(1);
// Cleanup
@unlink($logPath);
});
test('skipped jobs show fallback when resource is deleted', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// Temporarily rename existing logs so they don't interfere
$existingLogs = glob(storage_path('logs/scheduled-*.log'));
$renamed = [];
foreach ($existingLogs as $log) {
$tmp = $log.'.bak';
rename($log, $tmp);
$renamed[$tmp] = $log;
}
$lines = [
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
];
file_put_contents($logPath, implode("\n", $lines)."\n");
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('my-cron-job')
->assertSee('Application not running');
// Cleanup
@unlink($logPath);
foreach ($renamed as $tmp => $original) {
rename($tmp, $original);
}
});