| Time |
Type |
+ Resource |
Reason |
- Details |
@@ -214,6 +235,17 @@ class="border-b border-gray-200 dark:border-coolgray-400">
{{ ucfirst(str_replace('_', ' ', $skip['type'])) }}
+
+ @if($skip['link'] ?? null)
+
+ {{ $skip['resource_name'] }}
+
+ @elseif($skip['resource_name'] ?? null)
+ {{ $skip['resource_name'] }}
+ @else
+ {{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }}
+ @endif
+ |
@php
$reasonLabel = match($skip['reason']) {
@@ -235,15 +267,6 @@ class="border-b border-gray-200 dark:border-coolgray-400">
@endphp
{{ $reasonLabel }}
|
-
- @php
- $details = collect($skip['context'])
- ->except(['type', 'skip_reason', 'execution_time'])
- ->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v)
- ->implode(', ');
- @endphp
- {{ $details }}
- |
@empty
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 648849d5c..f32db9b8d 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -141,6 +141,15 @@ else
log "Network 'coolify' already exists"
fi
+# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
+# Only changes owner — preserves existing group to respect custom setups
+SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
+if [ "$SSH_OWNER" != "9999" ]; then
+ log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
+ chown -R 9999 /data/coolify/ssh
+ chmod -R 700 /data/coolify/ssh
+fi
+
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php
new file mode 100644
index 000000000..edfd0511c
--- /dev/null
+++ b/tests/Feature/CleanupUnreachableServersTest.php
@@ -0,0 +1,73 @@
+= 3 after 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 50,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe('1.2.3.4');
+});
+
+it('does not clean up servers with unreachable_count less than 3', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 2,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers updated within 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(3),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers without notification sent', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => false,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php
new file mode 100644
index 000000000..eb51059db
--- /dev/null
+++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php
@@ -0,0 +1,150 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 45;
+ });
+});
+
+it('does not dispatch storage check when disk percentage is unchanged', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached the percentage
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertNotPushed(ServerStorageCheckJob::class);
+});
+
+it('dispatches storage check when disk percentage changes from cached value', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached 45%
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 50],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 50;
+ });
+});
+
+it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ // First push: should dispatch ConnectProxyToNetworksJob
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited)
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertNotPushed(ConnectProxyToNetworksJob::class);
+});
+
+it('dispatches ConnectProxyToNetworksJob again after cache expires', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ // First push
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Clear cache to simulate expiration
+ Cache::forget('connect-proxy:'.$server->id);
+
+ // Next push: should dispatch again
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+});
+
+it('uses default queue for PushServerUpdateJob', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $job = new PushServerUpdateJob($server, ['containers' => []]);
+
+ expect($job->queue)->toBeNull();
+});
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
new file mode 100644
index 000000000..f820c3777
--- /dev/null
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -0,0 +1,222 @@
+getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('catches delayed job when cache has a baseline from previous run', function () {
+ // Simulate a previous dispatch yesterday at 02:00
+ Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
+ // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('does not double-dispatch on subsequent runs within same cron window', function () {
+ // First run at 02:00 — dispatches and sets cache
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($first)->toBeTrue();
+
+ // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($second)->toBeFalse();
+});
+
+it('fires every_minute cron correctly on consecutive minutes', function () {
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Minute 1
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result1)->toBeTrue();
+
+ // Minute 2
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result2)->toBeTrue();
+
+ // Minute 3
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result3)->toBeTrue();
+});
+
+it('does not fire non-due jobs on restart when cache is empty', function () {
+ // Time is 10:00, cron is daily at 02:00 — NOT due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ expect($result)->toBeFalse();
+});
+
+it('fires due jobs on restart when cache is empty', function () {
+ // Time is exactly 02:00, cron is daily at 02:00 — IS due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — but cron IS due → should fire
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
+ expect($result)->toBeTrue();
+});
+
+it('does not dispatch when cron is not due and was not recently due', function () {
+ // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
+ Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
+ expect($result)->toBeFalse();
+});
+
+it('falls back to isDue when no dedup key is provided', function () {
+ // Time is exactly 02:00, cron is "0 2 * * *" — should be due
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // No dedup key → simple isDue check
+ $result = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result)->toBeTrue();
+
+ // At 02:01 without dedup key → isDue returns false
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $result2 = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result2)->toBeFalse();
+});
+
+it('respects server timezone for cron evaluation', function () {
+ // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
+ Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
+ // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
+ $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
+ expect($resultSingapore)->toBeTrue();
+
+ // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
+ $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
+ expect($resultUtc)->toBeFalse();
+});
diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php
index 1348375d4..036c3b638 100644
--- a/tests/Feature/ScheduledJobMonitoringTest.php
+++ b/tests/Feature/ScheduledJobMonitoringTest.php
@@ -173,6 +173,42 @@
@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);
@@ -198,3 +234,39 @@
// 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);
+ }
+});
diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php
index 00f39e3df..09472604b 100644
--- a/tests/Unit/PrivateKeyStorageTest.php
+++ b/tests/Unit/PrivateKeyStorageTest.php
@@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable()
);
$this->expectException(\Exception::class);
- $this->expectExceptionMessage('SSH keys storage directory is not writable');
+ $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',
diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php
index 0f2613f11..d8449adc3 100644
--- a/tests/Unit/ServerManagerJobSentinelCheckTest.php
+++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php
@@ -1,6 +1,7 @@
instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel enabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
$server->id = 1;
$server->name = 'test-server';
$server->ip = '192.168.1.100';
@@ -34,29 +34,76 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
- return $job->server->id === $server->id;
- });
+ // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync
+ Queue::assertNotPushed(CheckAndStartSentinelJob::class);
});
-it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () {
- // Mock InstanceSettings
+it('skips ServerConnectionCheckJob when sentinel is live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now();
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is healthy so SSH connection check is skipped
+ Queue::assertNotPushed(ServerConnectionCheckJob::class);
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(false);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now()->subMinutes(10);
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is out of sync so SSH connection check is needed
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id;
+ });
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () {
$settings = Mockery::mock(InstanceSettings::class);
$settings->instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel disabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(false);
+ $server->shouldReceive('isSentinelLive')->never();
$server->id = 2;
$server->name = 'test-server-no-sentinel';
$server->ip = '192.168.1.101';
@@ -64,78 +111,14 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was NOT dispatched
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});
-
-it('respects server timezone when scheduling sentinel checks', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to top of hour in America/New_York (which is 17:00 UTC)
- Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST)
-
- // Create a mock server with sentinel enabled and America/New_York timezone
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 3;
- $server->name = 'test-server-est';
- $server->ip = '192.168.1.102';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone)
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
+ // Sentinel is not enabled so SSH connection check must run
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id;
});
});
-
-it('does not dispatch sentinel check when not at top of hour', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to middle of the hour (not top of hour)
- Carbon::setTestNow('2025-01-15 12:30:00');
-
- // Create a mock server with sentinel enabled
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 4;
- $server->name = 'test-server-mid-hour';
- $server->ip = '192.168.1.103';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour)
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});