3 - Add fedi-discover:poll command with failure isolation

This commit is contained in:
myrmidex 2026-04-25 10:27:29 +02:00
parent fea8d48f6e
commit 1b652752e1
3 changed files with 201 additions and 0 deletions

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Lvl0\FediDiscover\Actions\PollFediverseAction;
use Lvl0\FediDiscover\Models\Instance;
use Throwable;
#[Signature('fedi-discover:poll')]
#[Description('Poll all enabled fediverse instances for new URLs')]
class PollInstancesCommand extends Command
{
public function __construct(
private readonly PollFediverseAction $action
) {
parent::__construct();
}
public function handle(): int
{
$hadFailure = false;
Instance::enabled()
->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;
}
}

View file

@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lvl0\FediDiscover\Console\Commands\PollInstancesCommand;
use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand; use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand;
use Lvl0\FediDiscover\Events\UrlDiscovered; use Lvl0\FediDiscover\Events\UrlDiscovered;
@ -14,6 +15,11 @@ class FediDiscoverServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->mergeConfigFrom(__DIR__ . '/../config/fedi-discover.php', 'fedi-discover'); $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 public function boot(): void
@ -30,6 +36,7 @@ public function boot(): void
], 'fedi-discover-config'); ], 'fedi-discover-config');
$this->commands([ $this->commands([
PollInstancesCommand::class,
ValidateInstancesCommand::class, ValidateInstancesCommand::class,
]); ]);
} }

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Actions\PollFediverseAction;
use Lvl0\FediDiscover\Clients\FediverseClientInterface;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class PollInstancesCommandTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Bind a no-op stub so the command can resolve PollFediverseAction
// from the container without making real HTTP calls.
$stub = Mockery::mock(FediverseClientInterface::class);
$stub->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);
}
}