diff --git a/app/Listeners/PollFailedListener.php b/app/Listeners/PollFailedListener.php new file mode 100644 index 0000000..e501ff7 --- /dev/null +++ b/app/Listeners/PollFailedListener.php @@ -0,0 +1,18 @@ +service->recordFailure($event->instance, $event->message); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 30eaf8a..dfb03cd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,12 @@ namespace App\Providers; +use App\Listeners\PollFailedListener; use App\Listeners\UrlDiscoveredListener; use App\Services\LanguageDetectionService; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lvl0\FediDiscover\Events\PollFailed; use Lvl0\FediDiscover\Events\UrlDiscovered; class AppServiceProvider extends ServiceProvider @@ -18,5 +20,6 @@ public function register(): void public function boot(): void { Event::listen(UrlDiscovered::class, UrlDiscoveredListener::class); + Event::listen(PollFailed::class, PollFailedListener::class); } } diff --git a/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php b/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php index 41b9604..e92c496 100644 --- a/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php +++ b/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php @@ -9,6 +9,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; use Lvl0\FediDiscover\Actions\PollFediverseAction; +use Lvl0\FediDiscover\Events\PollFailed; use Lvl0\FediDiscover\Models\Instance; use Throwable; @@ -24,13 +25,13 @@ public function __construct( public function handle(): int { - $hadFailure = false; - - Instance::enabled() + $errors = Instance::enabled() ->get() - ->each(function (Instance $instance) use (&$hadFailure) { + ->map(function (Instance $instance) { try { $this->action->execute($instance); + + return ['instance_id' => $instance->id, 'status' => 'success']; } catch (Throwable $e) { $this->error("Failed to poll {$instance->url}: {$e->getMessage()}"); Log::warning('fedi-discover:poll failed', [ @@ -39,14 +40,22 @@ public function handle(): int 'exception' => $e::class, 'message' => $e->getMessage(), ]); - $hadFailure = true; - } - }); - if ($hadFailure) { - return self::FAILURE; + return ['instance' => $instance, 'status' => 'error', 'error' => $e->getMessage()]; + } + }) + ->filter(fn (array $res) => $res['status'] === 'error'); + + if ($errors->isEmpty()) { + return self::SUCCESS; } - return self::SUCCESS; + $errors->each(fn (array $errorArr) => PollFailed::dispatch( + $errorArr['instance'], + $errorArr['error'] ?? '', + now()->toImmutable(), + )); + + return self::FAILURE; } } diff --git a/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php index f1797c7..a449552 100644 --- a/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php +++ b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php @@ -5,10 +5,12 @@ namespace Lvl0\FediDiscover\Tests\Feature; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Lvl0\FediDiscover\Actions\PollFediverseAction; use Lvl0\FediDiscover\Clients\FediverseClientFactory; use Lvl0\FediDiscover\Clients\FediverseClientInterface; use Lvl0\FediDiscover\Config\InstanceType; +use Lvl0\FediDiscover\Events\PollFailed; use Lvl0\FediDiscover\Models\Instance; use Mockery; use RuntimeException; @@ -122,6 +124,52 @@ public function test_one_instance_throwing_does_not_stop_remaining_instances_fro ); } + public function test_poll_failed_event_is_dispatched_when_action_throws(): void + { + Event::fake([PollFailed::class]); + + $instance = Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://failing.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + $action = Mockery::mock(PollFediverseAction::class); + $action->shouldReceive('execute') + ->once() + ->andReturnUsing(function (): void { + throw new RuntimeException('Connection refused'); + }); + + $this->app->instance(PollFediverseAction::class, $action); + + $this->artisan('fedi-discover:poll'); + + Event::assertDispatched(PollFailed::class, function (PollFailed $event) use ($instance): bool { + return $event->instance->id === $instance->id + && $event->message === 'Connection refused'; + }); + } + + public function test_poll_failed_event_is_not_dispatched_on_a_successful_poll(): void + { + Event::fake([PollFailed::class]); + + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://healthy.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + // setUp() already binds a no-op action stub via the factory; no override needed. + + $this->artisan('fedi-discover:poll'); + + Event::assertNotDispatched(PollFailed::class); + } + public function test_it_exits_one_when_at_least_one_instance_fails(): void { Instance::create([ diff --git a/tests/Feature/Listeners/PollFailedListenerTest.php b/tests/Feature/Listeners/PollFailedListenerTest.php new file mode 100644 index 0000000..abd706b --- /dev/null +++ b/tests/Feature/Listeners/PollFailedListenerTest.php @@ -0,0 +1,52 @@ +type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 0]); + + $message = 'connection timed out'; + $failedAt = CarbonImmutable::now(); + $event = new PollFailed($instance, $message, $failedAt); + + $service = Mockery::mock(PollAlertService::class); + $service->shouldReceive('recordFailure') + ->once() + ->with( + Mockery::on(fn (Instance $i) => $i->is($instance)), + $message, + ); + + $listener = new PollFailedListener($service); + $listener->handle($event); + } + + public function test_listener_is_not_queued(): void + { + $this->assertNotInstanceOf( + ShouldQueue::class, + new PollFailedListener($this->createStub(PollAlertService::class)), + ); + } +} diff --git a/tests/Feature/PollFailedIntegrationTest.php b/tests/Feature/PollFailedIntegrationTest.php new file mode 100644 index 0000000..96dd973 --- /dev/null +++ b/tests/Feature/PollFailedIntegrationTest.php @@ -0,0 +1,37 @@ +type(InstanceType::Mastodon) + ->enabled() + ->create(['consecutive_poll_failures' => 0]); + + $this->mock(PollFediverseAction::class) + ->shouldReceive('execute') + ->once() + ->andThrow(new RuntimeException('connection refused')); + + $this->artisan('fedi-discover:poll'); + + $this->assertSame(1, $instance->fresh()->consecutive_poll_failures); + } +}