Files
coolify/tests/Feature/NotificationThrottleTest.php
T
Andras Bacsai 9e7cb60b0d feat: add import restore options, OAuth confirmation, and fixes
- database import: add keep_owners (restore PostgreSQL owners and
  privileges) and sqlite_database (pick the target SQLite file) to the
  API, OpenAPI spec, and import form
- oauth: require password confirmation and support team selection on
  OAuth login
- remote process: send team broadcast events to the team of the server
  that ran the process instead of the causer's user id
- servers API: handle the legacy build server setting
- compose: support parent bind paths and volume driver options
- dns: improve record hints and provider management
- notifications: fix throttling and missing backup notifications
- ui: rework the confirmation modal
- add feature and unit tests for the new behavior
2026-09-30 13:35:22 +02:00

140 lines
5.9 KiB
PHP

<?php
use App\Models\InstanceSettings;
use App\Models\NotificationThrottle;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Server\HighDiskUsage;
use App\Notifications\Server\Unreachable;
use Carbon\Carbon;
use Illuminate\Contracts\Notifications\Dispatcher as NotificationDispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
});
afterEach(function () {
Carbon::setTestNow();
});
it('claims a notification only once until it is released', function () {
expect(NotificationThrottle::claim($this->server, Unreachable::class))->toBeTrue()
->and(NotificationThrottle::claim($this->server, Unreachable::class))->toBeFalse()
->and($this->server->unreachable_notification_sent)->toBeTrue()
->and(NotificationThrottle::release($this->server, Unreachable::class))->toBeTrue()
->and(NotificationThrottle::release($this->server, Unreachable::class))->toBeFalse()
->and($this->server->unreachable_notification_sent)->toBeFalse()
->and(NotificationThrottle::claim($this->server, Unreachable::class))->toBeTrue();
});
it('claims a notification again only when it was sent before the given time', function () {
Carbon::setTestNow(Carbon::parse('2026-09-29 12:00:00', 'UTC'));
expect(NotificationThrottle::claim($this->server, HighDiskUsage::class, now()->subHours(6)))->toBeTrue();
Carbon::setTestNow(now()->addHours(5));
expect(NotificationThrottle::claim($this->server, HighDiskUsage::class, now()->subHours(6)))->toBeFalse();
Carbon::setTestNow(now()->addHours(2));
expect(NotificationThrottle::claim($this->server, HighDiskUsage::class, now()->subHours(6)))->toBeTrue()
->and(NotificationThrottle::query()->count())->toBe(1);
});
it('keeps throttles separate for each notification and resource', function () {
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
NotificationThrottle::record($this->server, Unreachable::class);
expect(NotificationThrottle::claim($this->server, HighDiskUsage::class))->toBeTrue()
->and(NotificationThrottle::claim($otherServer, Unreachable::class))->toBeTrue()
->and(NotificationThrottle::claim($this->server, Unreachable::class))->toBeFalse();
});
it('removes the throttles of a server when the server is force deleted', function () {
NotificationThrottle::record($this->server, Unreachable::class);
$this->server->forceDelete();
expect(NotificationThrottle::query()->count())->toBe(0);
});
it('keeps unreachable_notification_sent in the server API response', function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$token = $user->createToken('read-token', ['read']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
NotificationThrottle::record($this->server, Unreachable::class);
$this->withHeaders(['Authorization' => 'Bearer '.$token->plainTextToken])
->getJson('/api/v1/servers/'.$this->server->uuid)
->assertOk()
->assertJsonPath('unreachable_notification_sent', true);
});
function throttledDiskUsageNotification(Server $server): HighDiskUsage
{
return new HighDiskUsage($server, 95, 80);
}
it('releases the throttle claim when sending the notification throws', function () {
$sends = 0;
$this->mock(NotificationDispatcher::class, function ($mock) use (&$sends) {
$mock->shouldReceive('send')->twice()->andReturnUsing(function () use (&$sends) {
$sends++;
if ($sends === 1) {
throw new RuntimeException('SMTP is down');
}
});
});
expect(fn () => $this->team->notify(throttledDiskUsageNotification($this->server)))
->toThrow(RuntimeException::class, 'SMTP is down')
->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeFalse();
$this->team->notify(throttledDiskUsageNotification($this->server));
expect($sends)->toBe(2)
->and(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue();
});
it('throttles the notification after it was sent successfully', function () {
$this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once());
$this->team->notify(throttledDiskUsageNotification($this->server));
$this->team->notify(throttledDiskUsageNotification($this->server));
expect(NotificationThrottle::wasSent($this->server, HighDiskUsage::class))->toBeTrue();
});
it('does not send a notification that another worker is already sending', function () {
$notification = throttledDiskUsageNotification($this->server);
$concurrentClaim = null;
$this->mock(NotificationDispatcher::class, function ($mock) use ($notification, &$concurrentClaim) {
$mock->shouldReceive('send')->once()->andReturnUsing(function () use ($notification, &$concurrentClaim) {
$concurrentClaim = NotificationThrottle::claimFor($notification);
});
});
$this->team->notify($notification);
expect($concurrentClaim)->toBeFalse();
});
it('releases the unreachable claim when sending the unreachable notification throws', function () {
$this->mock(NotificationDispatcher::class, fn ($mock) => $mock->shouldReceive('send')->once()->andThrow(new RuntimeException('Discord is down')));
expect(fn () => $this->server->sendUnreachableNotification())->toThrow(RuntimeException::class)
->and(NotificationThrottle::wasSent($this->server, Unreachable::class))->toBeFalse();
});