toContain('x-on:keydown.enter="openSettings($event)"')
+ ->toContain("closest('a, button')")
+ ->toContain('role="link"')
+ ->toContain('tabindex="0"');
+});
+
+it('uses selected service resource actions instead of parent complex status actions', function () {
+ $heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
+ $headingClass = file_get_contents(app_path('Livewire/Project/Service/Heading.php'));
+
+ expect(substr_count($heading, "\$selectedResource && \$selectedResource->container_present !== false && \$selectedResourceStatus->startsWith('exited')"))->toBe(2)
+ ->and($heading)
+ ->toContain('Remove container')
+ ->toContain('removeSelectedResourceContainer')
+ ->and($headingClass)
+ ->toContain('public function removeSelectedResourceContainer(): void');
+});
+
+it('imports the application model used when claiming a restart limit', function () {
+ $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
+
+ expect($statusAction)
+ ->toContain('use App\\Models\\Application;')
+ ->toContain('Application::query()');
+});
+
+it('adds restart limit columns to previews services and standalone databases', function () {
+ $migrations = collect(glob(database_path('migrations/*.php')))
+ ->map(fn (string $path): string => file_get_contents($path))
+ ->implode("\n");
+
+ expect($migrations)
+ ->toContain("'application_previews'")
+ ->toContain("'service_applications'")
+ ->toContain("'service_databases'")
+ ->toContain("'max_restart_count'")
+ ->toContain("'restart_limit_reached'");
+
+ $restartLimitMigrations = collect(glob(database_path('migrations/*_add_restart_limit_to_*.php')));
+
+ expect($restartLimitMigrations)->toHaveCount(11);
+ expect($restartLimitMigrations->map(
+ fn (string $path): string => substr(basename($path), 0, 17)
+ )->unique())->toHaveCount(11);
+ $restartLimitMigrations->each(function (string $path): void {
+ expect(file_get_contents($path))->not->toContain('foreach (');
+ });
+});
+
+it('atomically claims a resource restart limit once and can reset it', function () {
+ Schema::create('restart_limit_test_resources', function (Blueprint $table): void {
+ $table->id();
+ $table->string('status')->default('running');
+ $table->integer('restart_count')->default(0);
+ $table->integer('max_restart_count')->default(2);
+ $table->boolean('restart_limit_reached')->default(false);
+ $table->timestamp('last_restart_at')->nullable();
+ $table->string('last_restart_type')->nullable();
+ $table->timestamps();
+ });
+
+ $resource = new class extends Model
+ {
+ use HasRestartLimit;
+
+ protected $table = 'restart_limit_test_resources';
+ };
+ $resource->save();
+ $resource->refresh();
+
+ expect($resource->trackRestartCount(2))->toBeTrue()
+ ->and($resource->fresh()->restart_limit_reached)->toBeTrue()
+ ->and($resource->trackRestartCount(2))->toBeFalse();
+
+ $resource->resetRestartLimit();
+
+ expect($resource->fresh()->restart_count)->toBe(0)
+ ->and($resource->restart_limit_reached)->toBeFalse();
+
+ $resourceWithExistingRestarts = $resource->newInstance();
+ $resourceWithExistingRestarts->max_restart_count = 0;
+ $resourceWithExistingRestarts->save();
+ expect($resourceWithExistingRestarts->trackRestartCount(17))->toBeFalse();
+
+ $resourceWithExistingRestarts->update(['max_restart_count' => 10]);
+ expect($resourceWithExistingRestarts->trackRestartCount(17))->toBeTrue()
+ ->and($resourceWithExistingRestarts->fresh()->restart_limit_reached)->toBeTrue();
+
+ Schema::drop('restart_limit_test_resources');
+});
diff --git a/tests/Feature/ApplicationContainerPresenceTest.php b/tests/Feature/ApplicationContainerPresenceTest.php
new file mode 100644
index 000000000..4a80631dd
--- /dev/null
+++ b/tests/Feature/ApplicationContainerPresenceTest.php
@@ -0,0 +1,56 @@
+forceFill(['container_present' => 1]);
+ $migration = file_get_contents(base_path('database/migrations/2026_08_30_193506_add_container_present_to_applications_table.php'));
+
+ expect($application->container_present)->toBeTrue()
+ ->and($migration)->toContain("boolean('container_present')->nullable()");
+});
+
+it('updates container presence at application lifecycle boundaries', function () {
+ $stopAction = file_get_contents(app_path('Actions/Application/StopApplication.php'));
+ $dockerStatus = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
+ $sentinelStatus = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
+
+ expect($stopAction)->toContain('$containerPresent = ! $removeContainers;')
+ ->and($stopAction)->toMatch('/if \(\$server->isSwarm\(\)\).*?\$containerPresent = false;.*?docker stack rm/s')
+ ->and($stopAction)->toContain("'container_present' => \$containerPresent")
+ ->and($dockerStatus)->toContain("'container_present' => true")
+ ->and($dockerStatus)->toContain("'container_present' => false")
+ ->and($sentinelStatus)->toContain("'container_present' => true")
+ ->and($sentinelStatus)->toContain("'container_present' => false");
+});
+
+it('shows accurate destructive actions and the restart warning on mobile', function () {
+ $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+ $mobileActions = str($heading)
+ ->after('id="application-mobile-actions"')
+ ->before('
')
+ ->toString();
+
+ expect($heading)->toContain('
')
+ ->and(substr_count($heading, '$application->container_present !== false'))->toBe(2)
+ ->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment' : 'Deploy'")
+ ->and($heading)->toContain("\$application->stoppedAfterRestartLimit() ? 'Retry deployment (without cache)' : 'Deploy (without cache)'")
+ ->and($heading)->toContain('Remove container')
+ ->and($mobileActions)->toContain('Deploy (without cache)')
+ ->and($mobileActions)->toContain('Remove container')
+ ->and(strrpos($mobileActions, 'Remove container'))->toBeGreaterThan(strrpos($mobileActions, 'Deploy (without cache)'));
+});
+
+it('shows restart limit reached as a yellow state in environment resource lists', function () {
+ $indexClass = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
+ $indexView = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
+
+ expect($indexClass)->toContain("'restartLimitReached' => \$type === 'application' && \$item->stoppedAfterRestartLimit()")
+ ->and($indexClass)->toContain('? max($item->restart_count ?? 0, $item->max_restart_count ?? 0)')
+ ->and($indexClass)->toContain("'maxRestartCount' => \$item->max_restart_count ?? 0")
+ ->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'restart-limit';")
+ ->and($indexView)->toContain("return 'Restart limit reached';")
+ ->and($indexView)->toContain("if (item.restartLimitReached) {\n return 'bg-warning';")
+ ->and($indexView)->toContain('x-bind:title="statusTitle(item)"');
+});
diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
index 6b82d5568..3e6e04629 100644
--- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
+++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php
@@ -1,16 +1,24 @@
forceFill(array_merge([
'status' => 'exited:unhealthy',
+ 'container_present' => true,
'restart_count' => 2,
'max_restart_count' => 2,
+ 'restart_limit_reached' => true,
'last_restart_type' => 'crash',
'last_restart_at' => now(),
], $attributes));
@@ -21,9 +29,51 @@ function applicationWithRestartState(array $attributes = []): Application
it('detects applications stopped after reaching the crash restart limit', function () {
expect(applicationWithRestartState()->stoppedAfterRestartLimit())->toBeTrue()
->and(applicationWithRestartState(['status' => 'running:unhealthy'])->stoppedAfterRestartLimit())->toBeFalse()
- ->and(applicationWithRestartState(['restart_count' => 1])->stoppedAfterRestartLimit())->toBeFalse()
- ->and(applicationWithRestartState(['max_restart_count' => 0])->stoppedAfterRestartLimit())->toBeFalse()
- ->and(applicationWithRestartState(['last_restart_type' => null])->stoppedAfterRestartLimit())->toBeFalse();
+ ->and(applicationWithRestartState(['restart_limit_reached' => false])->stoppedAfterRestartLimit())->toBeFalse();
+});
+
+it('keeps the restart limit state after Docker resets its counter', function () {
+ expect(applicationWithRestartState([
+ 'restart_count' => 0,
+ 'last_restart_type' => null,
+ 'last_restart_at' => null,
+ ])->stoppedAfterRestartLimit())->toBeTrue();
+});
+
+it('preserves exited application state when the container snapshot is empty', function () {
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->setRelation('additional_servers', collect());
+ $application->forceFill([
+ 'id' => 1,
+ 'status' => 'exited:unhealthy',
+ 'container_present' => true,
+ 'restart_limit_reached' => true,
+ ]);
+ $application->shouldNotReceive('update');
+
+ $services = Mockery::mock();
+ $services->shouldReceive('get')->once()->andReturn(collect());
+
+ $server = Mockery::mock(Server::class, function (MockInterface $mock) use ($application, $services) {
+ $mock->shouldReceive('isFunctional')->once()->andReturnTrue();
+ $mock->shouldReceive('applications')->once()->andReturn(collect([$application]));
+ $mock->shouldReceive('databases')->once()->andReturn(collect());
+ $mock->shouldReceive('services')->once()->andReturn($services);
+ $mock->shouldReceive('previews')->once()->andReturn(collect());
+ })->makePartial();
+ $server->setRelation('team', (object) ['id' => 1]);
+
+ GetContainersStatus::run($server, collect(), collect());
+
+ expect($application->container_present)->toBeTrue()
+ ->and($application->restart_limit_reached)->toBeTrue();
+});
+
+it('does not infer the restart limit from an exited existing container', function () {
+ expect(applicationWithRestartState([
+ 'container_present' => true,
+ 'restart_limit_reached' => false,
+ ])->stoppedAfterRestartLimit())->toBeFalse();
});
it('shows a stopped after restart limit warning in the status badge', function () {
@@ -32,7 +82,8 @@ function applicationWithRestartState(array $attributes = []): Application
'showRefreshButton' => false,
])->render();
- expect($html)->toContain('Stopped after reaching restart limit (2/2).')
+ expect($html)->toContain('Restart limit reached')
+ ->not->toContain('Stopped after reaching restart limit (2/2).')
->and($html)->toContain('Container has crashed and Coolify stopped it after 2 restart attempts.');
});
@@ -41,11 +92,12 @@ function applicationWithRestartState(array $attributes = []): Application
'resource' => applicationWithRestartState([
'restart_count' => 0,
'last_restart_type' => null,
+ 'restart_limit_reached' => false,
]),
'showRefreshButton' => false,
])->render();
- expect($html)->not->toContain('Stopped after reaching restart limit');
+ expect($html)->not->toContain('Restart limit reached');
});
it('keeps restart tracking configurable when stopping an application', function () {
@@ -56,6 +108,59 @@ function applicationWithRestartState(array $attributes = []): Application
->and($resetRestartCount->getDefaultValue())->toBeTrue();
});
+it('can stop an application without removing its containers', function () {
+ $method = new ReflectionMethod(StopApplication::class, 'handle');
+ $removeContainers = collect($method->getParameters())->firstWhere('name', 'removeContainers');
+ $action = file_get_contents(app_path('Actions/Application/StopApplication.php'));
+
+ expect($removeContainers)->not->toBeNull()
+ ->and($removeContainers->getDefaultValue())->toBeTrue()
+ ->and($action)->toContain('docker update --restart=no')
+ ->and($action)->toContain('if ($removeContainers)');
+});
+
+it('preserves containers and skips cleanup when the restart limit is reached', function () {
+ $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
+ $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
+
+ expect($statusAction)->toContain('dockerCleanup: false')
+ ->and($statusAction)->toContain('resetRestartCount: false')
+ ->and($statusAction)->toContain('removeContainers: false')
+ ->and($statusAction)->toContain("['restart_limit_reached' => true]")
+ ->and($sentinelJob)->toContain("['restart_limit_reached' => true]");
+});
+
+it('atomically claims the restart limit transition before stopping and notifying', function () {
+ $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
+ $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
+
+ foreach ([$statusAction, $sentinelJob] as $detector) {
+ expect($detector)
+ ->toContain("->where('restart_limit_reached', false)")
+ ->toContain("->update(['restart_limit_reached' => true]) === 1");
+ }
+});
+
+it('clears the explicit restart limit state only after a successful main deployment', function () {
+ $method = new ReflectionMethod(ApplicationDeploymentJob::class, 'handleSuccessfulDeployment');
+ $source = file($method->getFileName());
+ $deploymentJob = implode(array_slice($source, $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1));
+
+ expect(substr_count($deploymentJob, "'restart_limit_reached'] = false"))->toBe(1)
+ ->and($deploymentJob)->toContain("if (\$this->pull_request_id === 0) {\n \$restartState['restart_limit_reached'] = false;\n }")
+ ->and($deploymentJob)->toContain('$this->application->update($restartState);')
+ ->and(substr_count($deploymentJob, '$this->application->update('))->toBe(1);
+});
+
+it('preserves restart-limit applications only while their exited container exists', function () {
+ $statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
+ $sentinelJob = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
+
+ expect($statusAction)->toContain("'container_present' => false")
+ ->and($statusAction)->toContain("'restart_limit_reached' => false")
+ ->and($sentinelJob)->toContain('if ($application->stoppedAfterRestartLimit() && $containerStatuses->every(');
+});
+
it('uses the application link for restart limit notifications', function () {
$application = new class extends Application
{
@@ -80,3 +185,55 @@ public function link()
expect($notification->resource_url)->toBe('https://coolify.test/project/link-from-model');
});
+
+it('uses the resolved environment project name in Slack restart limit notifications', function () {
+ $environment = (object) [
+ 'uuid' => 'environment-uuid',
+ 'name' => 'production',
+ 'project' => (object) [
+ 'uuid' => 'project-uuid',
+ 'name' => 'Coolify',
+ ],
+ ];
+
+ $application = new class extends Application
+ {
+ public function link(): string
+ {
+ return 'https://coolify.test/application';
+ }
+ };
+ $application->forceFill(['name' => 'app']);
+ $application->setRelation('environment', $environment);
+
+ $preview = new ApplicationPreview;
+ $preview->forceFill([
+ 'uuid' => 'preview-uuid',
+ 'pull_request_id' => 42,
+ 'restart_count' => 2,
+ 'max_restart_count' => 2,
+ ]);
+ $preview->setRelation('application', $application);
+
+ $serviceResource = new class extends BaseModel {};
+ $serviceResource->forceFill([
+ 'name' => 'database',
+ 'uuid' => 'service-resource-uuid',
+ 'restart_count' => 2,
+ 'max_restart_count' => 2,
+ ]);
+ $serviceResource->setRelation('service', new class($environment)
+ {
+ public function __construct(public object $environment) {}
+
+ public function link(): string
+ {
+ return 'https://coolify.test/service';
+ }
+ });
+
+ expect((new RestartLimitReached($preview))->toSlack()->description)
+ ->toContain('*Project:* Coolify')
+ ->and((new RestartLimitReached($serviceResource))->toSlack()->description)
+ ->toContain('*Project:* Coolify');
+});
diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php
index 793f76b1f..84b4ace6f 100644
--- a/tests/Feature/Authorization/NotificationAuthorizationTest.php
+++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php
@@ -132,6 +132,80 @@
expect($this->admin->can('update', $settings))->toBeTrue();
});
+test('telegram restart limit thread id accepts 255 characters', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(TelegramNotification::class)
+ ->set('telegramNotificationsRestartLimitReachedThreadId', str_repeat('a', 255))
+ ->call('syncData', true)
+ ->assertHasNoErrors(['telegramNotificationsRestartLimitReachedThreadId']);
+
+ expect($this->team->telegramNotificationSettings->fresh()->telegram_notifications_restart_limit_reached_thread_id)
+ ->toBe(str_repeat('a', 255));
+});
+
+test('telegram restart limit thread id rejects 256 characters', function () {
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ Livewire::test(TelegramNotification::class)
+ ->set('telegramNotificationsRestartLimitReachedThreadId', str_repeat('a', 256))
+ ->call('syncData', true)
+ ->assertHasErrors(['telegramNotificationsRestartLimitReachedThreadId' => 'max']);
+});
+
+test('member cannot view telegram thread ids', function () {
+ $threadIds = [
+ 'telegram_notifications_deployment_success_thread_id' => 'deployment-success-thread',
+ 'telegram_notifications_deployment_failure_thread_id' => 'deployment-failure-thread',
+ 'telegram_notifications_status_change_thread_id' => 'status-change-thread',
+ 'telegram_notifications_restart_limit_reached_thread_id' => 'restart-limit-thread',
+ 'telegram_notifications_backup_success_thread_id' => 'backup-success-thread',
+ 'telegram_notifications_backup_failure_thread_id' => 'backup-failure-thread',
+ 'telegram_notifications_scheduled_task_success_thread_id' => 'scheduled-task-success-thread',
+ 'telegram_notifications_scheduled_task_failure_thread_id' => 'scheduled-task-failure-thread',
+ 'telegram_notifications_docker_cleanup_success_thread_id' => 'docker-cleanup-success-thread',
+ 'telegram_notifications_docker_cleanup_failure_thread_id' => 'docker-cleanup-failure-thread',
+ 'telegram_notifications_server_disk_usage_thread_id' => 'server-disk-usage-thread',
+ 'telegram_notifications_server_reachable_thread_id' => 'server-reachable-thread',
+ 'telegram_notifications_server_unreachable_thread_id' => 'server-unreachable-thread',
+ 'telegram_notifications_server_patch_thread_id' => 'server-patch-thread',
+ 'telegram_notifications_traefik_outdated_thread_id' => 'traefik-outdated-thread',
+ ];
+
+ $this->team->telegramNotificationSettings->update($threadIds);
+
+ $this->actingAs($this->member);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(TelegramNotification::class);
+
+ foreach ($threadIds as $column => $threadId) {
+ $component
+ ->assertSet(str($column)->camel()->toString(), null)
+ ->assertDontSee($threadId);
+ }
+});
+
+test('admin can view telegram thread ids', function () {
+ $threadIds = [
+ 'telegram_notifications_deployment_success_thread_id' => 'deployment-success-thread',
+ 'telegram_notifications_restart_limit_reached_thread_id' => 'restart-limit-thread',
+ ];
+
+ $this->team->telegramNotificationSettings->update($threadIds);
+
+ $this->actingAs($this->admin);
+ session(['currentTeam' => $this->team]);
+
+ $component = Livewire::test(TelegramNotification::class);
+
+ foreach ($threadIds as $column => $threadId) {
+ $component->assertSet(str($column)->camel()->toString(), $threadId);
+ }
+});
+
// --- Email ---
test('member cannot send test email notification', function () {
diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
index f6991025c..9d6e19a98 100644
--- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
+++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php
@@ -53,6 +53,22 @@
'server actions' => ['views/livewire/server/navbar.blade.php', 'manageProxy', 'server', 'server'],
]);
+it('declares deploy authorization on the application stop confirmation', function () {
+ $source = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+
+ expect($source)->toMatch(
+ '/
toMatch(
+ '/]*title="Confirm Container Removal\?")(?=[^>]*canGate="deploy")(?=[^>]*:canResource="\$service")[^>]*>/'
+ );
+});
+
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
diff --git a/tests/Feature/NotificationRestartLimitSettingTest.php b/tests/Feature/NotificationRestartLimitSettingTest.php
new file mode 100644
index 000000000..e068ea594
--- /dev/null
+++ b/tests/Feature/NotificationRestartLimitSettingTest.php
@@ -0,0 +1,48 @@
+toContain("getEnabledChannels('restart_limit_reached')")
+ ->and($eventGrid)
+ ->toContain("'Resources' => [")
+ ->toContain("'key' => 'statusChange'")
+ ->toContain("'helper' => 'Notify when a resource stops or Coolify automatically restarts it.'")
+ ->toContain("'key' => 'restartLimitReached'")
+ ->toContain("'label' => 'Restart limit reached'")
+ ->and($telegramChannel)
+ ->toContain('RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id');
+});
+
+it('persists a restart limit notification preference for every channel', function (string $channel) {
+ $studly = Str::studly($channel);
+ $component = file_get_contents(app_path("Livewire/Notifications/{$studly}.php"));
+ $model = file_get_contents(app_path('Models/'.$studly.'NotificationSettings.php'));
+ $column = "restart_limit_reached_{$channel}_notifications";
+ $property = "restartLimitReached{$studly}Notifications";
+
+ expect($component)
+ ->toContain("public bool \${$property} = true;")
+ ->toContain("\$this->settings->{$column} = \$this->{$property};")
+ ->toContain("\$this->{$property} = \$this->settings->{$column};")
+ ->and($model)
+ ->toContain("'{$column}'");
+})->with(['email', 'discord', 'telegram', 'slack', 'pushover', 'webhook']);
+
+it('enables restart limit notifications by default in every channel migration', function () {
+ $migrations = collect(glob(database_path('migrations/*_add_restart_limit_reached_notifications_to_*')));
+
+ expect($migrations)->toHaveCount(6);
+ $migrations->each(fn (string $migration) => expect(file_get_contents($migration))->toContain('->default(true)'));
+});
+
+it('uses the inline validator facade for notification API updates', function () {
+ $controller = file_get_contents(app_path('Http/Controllers/Api/NotificationsController.php'));
+
+ expect($controller)
+ ->toContain('use Illuminate\\Support\\Facades\\Validator;')
+ ->toContain("Validator::make(\$body, \$config['rules'])")
+ ->not->toContain("customApiValidator(\$body, \$config['rules'])");
+});
diff --git a/tests/Feature/PreviewStatusSummaryTest.php b/tests/Feature/PreviewStatusSummaryTest.php
index dbc2b3a29..a31fd836d 100644
--- a/tests/Feature/PreviewStatusSummaryTest.php
+++ b/tests/Feature/PreviewStatusSummaryTest.php
@@ -19,6 +19,16 @@
->toContain('w-[min(16rem,calc(100vw-1.5rem))]!');
});
+it('shows degraded aggregate service status as a warning', function () {
+ $html = Blade::render('');
+ $summaryButton = str($html)->between('