From 1b652752e1ca635d18ec8131d115f718c257a5f9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 25 Apr 2026 10:27:29 +0200 Subject: [PATCH] 3 - Add fedi-discover:poll command with failure isolation --- .../Console/Commands/PollInstancesCommand.php | 44 +++++ .../src/FediDiscoverServiceProvider.php | 7 + .../Feature/PollInstancesCommandTest.php | 150 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php create mode 100644 packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php diff --git a/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php b/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php new file mode 100644 index 0000000..0f8d7d9 --- /dev/null +++ b/packages/Lvl0/FediDiscover/src/Console/Commands/PollInstancesCommand.php @@ -0,0 +1,44 @@ +get() + ->each(function (Instance $instance) use (&$hadFailure) { + try { + $this->action->execute($instance); + } catch (Throwable $e) { + $hadFailure = true; + } + }); + + if ($hadFailure) { + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php b/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php index 203355c..9fcb44f 100644 --- a/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php +++ b/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lvl0\FediDiscover\Console\Commands\PollInstancesCommand; use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand; use Lvl0\FediDiscover\Events\UrlDiscovered; @@ -14,6 +15,11 @@ class FediDiscoverServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/fedi-discover.php', 'fedi-discover'); + + $this->app->bind( + \Lvl0\FediDiscover\Clients\FediverseClientInterface::class, + \Lvl0\FediDiscover\Clients\MastodonClient::class, + ); } public function boot(): void @@ -30,6 +36,7 @@ public function boot(): void ], 'fedi-discover-config'); $this->commands([ + PollInstancesCommand::class, ValidateInstancesCommand::class, ]); } diff --git a/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php new file mode 100644 index 0000000..31ac841 --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php @@ -0,0 +1,150 @@ +shouldReceive('fetchPostsSince')->andReturn(collect()); + + $this->app->bind(FediverseClientInterface::class, fn () => $stub); + } + + public function test_it_exits_zero_when_there_are_no_enabled_instances(): void + { + $this->artisan('fedi-discover:poll') + ->assertExitCode(0); + } + + public function test_it_calls_the_action_for_each_enabled_instance_and_skips_disabled(): void + { + $enabled1 = Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + $enabled2 = Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://fosstodon.org', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://disabled.example', + 'enabled' => false, + 'interval_seconds' => 600, + ]); + + $calledWith = []; + + $action = Mockery::mock(PollFediverseAction::class); + $action->shouldReceive('execute') + ->twice() + ->withArgs(function (Instance $instance) use (&$calledWith): bool { + $calledWith[] = $instance->url; + + return true; + }); + + $this->app->instance(PollFediverseAction::class, $action); + + $this->artisan('fedi-discover:poll')->assertExitCode(0); + + $this->assertEqualsCanonicalizing( + [$enabled1->url, $enabled2->url], + $calledWith, + ); + } + + public function test_one_instance_throwing_does_not_stop_remaining_instances_from_being_polled(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://failing.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + $healthy = Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://healthy.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + $calledWith = []; + + $action = Mockery::mock(PollFediverseAction::class); + $action->shouldReceive('execute') + ->twice() + ->andReturnUsing(function (Instance $instance) use (&$calledWith): void { + $calledWith[] = $instance->url; + + if ($instance->url === 'https://failing.example') { + throw new RuntimeException('Connection refused'); + } + }); + + $this->app->instance(PollFediverseAction::class, $action); + + $this->artisan('fedi-discover:poll')->assertExitCode(1); + + $this->assertEqualsCanonicalizing( + ['https://failing.example', $healthy->url], + $calledWith, + ); + } + + public function test_it_exits_one_when_at_least_one_instance_fails(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://failing.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://healthy.example', + 'enabled' => true, + 'interval_seconds' => 600, + ]); + + $action = Mockery::mock(PollFediverseAction::class); + $action->shouldReceive('execute') + ->twice() + ->andReturnUsing(function (Instance $instance): void { + if ($instance->url === 'https://failing.example') { + throw new RuntimeException('Connection refused'); + } + }); + + $this->app->instance(PollFediverseAction::class, $action); + + $this->artisan('fedi-discover:poll')->assertExitCode(1); + } +}