From 6ab175a466b928eab894787c95f423c20944c192 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 29 Apr 2026 21:25:11 +0200 Subject: [PATCH] 6 - Send ntfy alert when poll failures cross threshold --- .env.example | 4 + .../Controllers/Admin/InstancesController.php | 1 + app/Services/PollAlertService.php | 26 +++- config/services.php | 6 + .../Lvl0/FediDiscover/src/Models/Instance.php | 1 - .../Feature/Admin/InstancesAdminPageTest.php | 9 +- .../Feature/Services/PollAlertServiceTest.php | 145 +++++++++++++++++- 7 files changed, 183 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index f83cd0b..ac89b76 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" CRAWLER_MIN_DOMAIN_DELAY_SECONDS=10 + +NTFY_URL= +NTFY_TOPIC= +NTFY_THRESHOLD= diff --git a/app/Http/Controllers/Admin/InstancesController.php b/app/Http/Controllers/Admin/InstancesController.php index d47702d..266fe7b 100644 --- a/app/Http/Controllers/Admin/InstancesController.php +++ b/app/Http/Controllers/Admin/InstancesController.php @@ -1,4 +1,5 @@ increment('consecutive_poll_failures'); + $instance->refresh(); + + $ntfyUrl = config('services.ntfy.url'); + $ntfyThreshold = (int) config('services.ntfy.threshold'); + $ntfyTopic = config('services.ntfy.topic'); + + if ($ntfyUrl === null || $ntfyThreshold === 0 || $ntfyTopic === null) { + return; + } + + if ($instance->consecutive_poll_failures < $ntfyThreshold) { + return; + } + + try { + Http::timeout(5) + ->withBody($instance->url . ' - ' . $message, 'text/plain') + ->post(rtrim($ntfyUrl, '/') . '/' . $ntfyTopic); + } catch (Exception $e) { + logger()->warning('ntfy alert failed', ['instance' => $instance->url, 'error' => $e->getMessage()]); + } } } diff --git a/config/services.php b/config/services.php index 6a90eb8..93fd034 100644 --- a/config/services.php +++ b/config/services.php @@ -14,6 +14,12 @@ | */ + 'ntfy' => [ + 'url' => env('NTFY_URL') ?: null, + 'topic' => env('NTFY_TOPIC') ?: null, + 'threshold' => env('NTFY_THRESHOLD'), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/packages/Lvl0/FediDiscover/src/Models/Instance.php b/packages/Lvl0/FediDiscover/src/Models/Instance.php index f2c6b87..9d61119 100644 --- a/packages/Lvl0/FediDiscover/src/Models/Instance.php +++ b/packages/Lvl0/FediDiscover/src/Models/Instance.php @@ -61,5 +61,4 @@ public function pages(): HasMany { return $this->hasMany(Page::class); } - } diff --git a/tests/Feature/Admin/InstancesAdminPageTest.php b/tests/Feature/Admin/InstancesAdminPageTest.php index 46a0070..fb633d7 100644 --- a/tests/Feature/Admin/InstancesAdminPageTest.php +++ b/tests/Feature/Admin/InstancesAdminPageTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Admin; +use App\Enums\PageStatusEnum; use App\Models\Page; use Illuminate\Foundation\Testing\RefreshDatabase; use Lvl0\FediDiscover\Config\InstanceType; @@ -63,23 +64,23 @@ public function test_admin_instances_page_shows_error_count_per_instance(): void Page::factory() ->count(3) ->sequence(fn ($s) => ['url' => "https://aardvark.example/fail-{$s->index}"]) - ->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Failed]); + ->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Failed]); Page::factory() ->count(2) ->sequence(fn ($s) => ['url' => "https://aardvark.example/ok-{$s->index}"]) - ->createQuietly(['instance_id' => $first->id, 'status' => \App\Enums\PageStatusEnum::Fetched]); + ->createQuietly(['instance_id' => $first->id, 'status' => PageStatusEnum::Fetched]); // Second instance: 1 failed + 4 non-failed pages Page::factory() ->count(1) ->sequence(fn ($s) => ['url' => "https://zebra.example/fail-{$s->index}"]) - ->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Failed]); + ->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Failed]); Page::factory() ->count(4) ->sequence(fn ($s) => ['url' => "https://zebra.example/ok-{$s->index}"]) - ->createQuietly(['instance_id' => $second->id, 'status' => \App\Enums\PageStatusEnum::Fetched]); + ->createQuietly(['instance_id' => $second->id, 'status' => PageStatusEnum::Fetched]); $response = $this->get('/admin/instances'); diff --git a/tests/Feature/Services/PollAlertServiceTest.php b/tests/Feature/Services/PollAlertServiceTest.php index a1e7a5e..714f359 100644 --- a/tests/Feature/Services/PollAlertServiceTest.php +++ b/tests/Feature/Services/PollAlertServiceTest.php @@ -6,6 +6,7 @@ use App\Services\PollAlertService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Models\Instance; use Tests\TestCase; @@ -14,19 +15,157 @@ class PollAlertServiceTest extends TestCase { use RefreshDatabase; - public function test_recordFailure_increments_consecutive_poll_failures_on_the_instance(): void + public function test_record_failure_increments_consecutive_poll_failures_on_the_instance(): void { $instance = Instance::factory() ->type(InstanceType::Mastodon) ->enabled() ->create(['consecutive_poll_failures' => 0]); - $service = new PollAlertService(); - $service->recordFailure($instance); + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); $this->assertDatabaseHas('fedi_discover_instances', [ 'id' => $instance->id, 'consecutive_poll_failures' => 1, ]); } + + public function test_no_alert_sent_below_threshold(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => 'trove-alerts', + 'services.ntfy.threshold' => 3, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 1]); // will become 2 after recordFailure + + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); + + Http::assertNothingSent(); + } + + public function test_alert_sent_when_threshold_is_reached(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => 'trove-alerts', + 'services.ntfy.threshold' => 3, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = exactly at threshold + + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://ntfy.example.com/trove-alerts' + && $request->method() === 'POST'; + }); + } + + public function test_alert_sent_when_count_exceeds_threshold(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => 'trove-alerts', + 'services.ntfy.threshold' => 3, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 3]); // will become 4 after recordFailure = above threshold + + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); + + Http::assertSent(function ($request) { + return $request->url() === 'https://ntfy.example.com/trove-alerts' + && $request->method() === 'POST'; + }); + } + + public function test_no_alert_sent_when_threshold_is_zero(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => 'trove-alerts', + 'services.ntfy.threshold' => 0, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 5]); + + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); + + Http::assertNothingSent(); + } + + public function test_no_alert_sent_when_topic_is_null(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => null, + 'services.ntfy.threshold' => 3, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 2]); // will become 3 after recordFailure = at threshold + + $service = new PollAlertService; + $service->recordFailure($instance, 'test'); + + Http::assertNothingSent(); + } + + public function test_alert_body_contains_instance_url_and_message(): void + { + Http::fake(); + + config([ + 'services.ntfy.url' => 'https://ntfy.example.com', + 'services.ntfy.topic' => 'trove-alerts', + 'services.ntfy.threshold' => 3, + ]); + + $instance = Instance::factory() + ->type(InstanceType::Mastodon) + ->enabled() + ->create([ + 'url' => 'https://mastodon.social', + 'consecutive_poll_failures' => 2, // will become 3 = at threshold + ]); + + $service = new PollAlertService; + $service->recordFailure($instance, 'connection refused after 3 retries'); + + Http::assertSent(function ($request) { + return str_contains($request->body(), 'https://mastodon.social') + && str_contains($request->body(), 'connection refused after 3 retries'); + }); + } }