From 52d6b493cbfe53c924c94bcd0f0cb2a6055ea0ac Mon Sep 17 00:00:00 2001 From: myrmidex Date: Fri, 24 Apr 2026 19:55:43 +0200 Subject: [PATCH] 2 - Add fedi-discover:validate console command --- .../Commands/ValidateInstancesCommand.php | 64 +++++ .../Database/Factories/InstanceFactory.php | 29 ++- .../src/FediDiscoverServiceProvider.php | 5 + .../Lvl0/FediDiscover/src/Models/Instance.php | 15 +- .../Feature/InstanceConfigPersistenceTest.php | 57 +++++ .../Feature/ValidateInstancesCommandTest.php | 221 ++++++++++++++++++ 6 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 packages/Lvl0/FediDiscover/src/Console/Commands/ValidateInstancesCommand.php create mode 100644 packages/Lvl0/FediDiscover/tests/Feature/InstanceConfigPersistenceTest.php create mode 100644 packages/Lvl0/FediDiscover/tests/Feature/ValidateInstancesCommandTest.php diff --git a/packages/Lvl0/FediDiscover/src/Console/Commands/ValidateInstancesCommand.php b/packages/Lvl0/FediDiscover/src/Console/Commands/ValidateInstancesCommand.php new file mode 100644 index 0000000..99cbcd1 --- /dev/null +++ b/packages/Lvl0/FediDiscover/src/Console/Commands/ValidateInstancesCommand.php @@ -0,0 +1,64 @@ +option('enabled-only')) { + $instances->enabled(); + } + + $instances = $instances->get(); + + $invalidInstances = collect(); + + $instances->each(function (Instance $instance) use ($invalidInstances) { + $reasons = collect(); + + if (filter_var($instance->url, FILTER_VALIDATE_URL) === false) { + $reasons->add('Invalid URL: ' . $instance->url); + } + + if ($instance->interval_seconds < 1) { + $reasons->add('Invalid interval seconds: ' . $instance->interval_seconds); + } + + if ($reasons->isNotEmpty()) { + $invalidInstances->add([ + 'instance' => $instance, + 'reasons' => $reasons, + ]); + } + }); + + $this->info((string) $instances->count()); + $this->info(($instances->count() - $invalidInstances->count()) . ' valid'); + $this->line($invalidInstances->count() . ' invalid'); + + if ($invalidInstances->isNotEmpty()) { + $invalidInstances->each(function (array $instanceArray) { + $instance = $instanceArray['instance']; + $reason = $instanceArray['reasons']->join(', '); + $this->warn($instance->id . ' - ' . $instance->url); + $this->line(' : ' . $reason); + }); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/packages/Lvl0/FediDiscover/src/Database/Factories/InstanceFactory.php b/packages/Lvl0/FediDiscover/src/Database/Factories/InstanceFactory.php index f6dc60d..b8df1f8 100644 --- a/packages/Lvl0/FediDiscover/src/Database/Factories/InstanceFactory.php +++ b/packages/Lvl0/FediDiscover/src/Database/Factories/InstanceFactory.php @@ -5,6 +5,7 @@ namespace Lvl0\FediDiscover\Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Models\Instance; /** @@ -20,7 +21,33 @@ class InstanceFactory extends Factory public function definition(): array { return [ - // + 'type' => null, + 'url' => fake()->url, + 'enabled' => null, + 'interval_seconds' => 600, + 'extras' => [], + 'last_polled_at' => now(), ]; } + + public function type(InstanceType $type): self + { + return $this->state(fn () => [ + 'type' => $type->value, + ]); + } + + public function enabled(): self + { + return $this->state(fn () => [ + 'enabled' => true, + ]); + } + + public function disabled(): self + { + return $this->state(fn () => [ + 'enabled' => false, + ]); + } } diff --git a/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php b/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php index 289b2fd..8eeb73e 100644 --- a/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php +++ b/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php @@ -5,6 +5,7 @@ namespace Lvl0\FediDiscover; use Illuminate\Support\ServiceProvider; +use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand; class FediDiscoverServiceProvider extends ServiceProvider { @@ -21,6 +22,10 @@ public function boot(): void $this->publishes([ __DIR__ . '/../config/fedi-discover.php' => config_path('fedi-discover.php'), ], 'fedi-discover-config'); + + $this->commands([ + ValidateInstancesCommand::class, + ]); } } } diff --git a/packages/Lvl0/FediDiscover/src/Models/Instance.php b/packages/Lvl0/FediDiscover/src/Models/Instance.php index a9be464..866ef17 100644 --- a/packages/Lvl0/FediDiscover/src/Models/Instance.php +++ b/packages/Lvl0/FediDiscover/src/Models/Instance.php @@ -4,12 +4,25 @@ namespace Lvl0\FediDiscover\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Database\Factories\InstanceFactory; +/** + * @property int $id + * @property InstanceType $type + * @property string $url + * @property bool $enabled + * @property int $interval_seconds + * @property array $extras + * @property Carbon|null $last_polled_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ class Instance extends Model { /** @use HasFactory */ @@ -26,7 +39,7 @@ class Instance extends Model 'last_polled_at' => 'datetime', ]; - public function scopeEnabled($query) + public function scopeEnabled($query): Builder { return $query->where('enabled', true); } diff --git a/packages/Lvl0/FediDiscover/tests/Feature/InstanceConfigPersistenceTest.php b/packages/Lvl0/FediDiscover/tests/Feature/InstanceConfigPersistenceTest.php new file mode 100644 index 0000000..7f8bf43 --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/InstanceConfigPersistenceTest.php @@ -0,0 +1,57 @@ + InstanceType::Mastodon->value, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => ['token' => 'abc123'], + ]); + + Instance::create($config->toArray()); + + $this->artisan('fedi-discover:validate') + ->assertExitCode(0); + } + + public function test_an_instance_config_survives_a_write_read_cycle_through_the_model(): void + { + $original = InstanceConfig::fromArray([ + 'type' => InstanceType::Mastodon->value, + 'url' => 'https://hachyderm.io', + 'enabled' => false, + 'interval_seconds' => 900, + 'extras' => ['foo' => 'bar'], + ]); + + Instance::create($original->toArray()); + + $instance = Instance::query()->firstOrFail(); + + $roundTripped = InstanceConfig::fromArray([ + 'type' => $instance->type->value, + 'url' => $instance->url, + 'enabled' => $instance->enabled, + 'interval_seconds' => $instance->interval_seconds, + 'extras' => $instance->extras, + ]); + + $this->assertEquals($original, $roundTripped); + } +} diff --git a/packages/Lvl0/FediDiscover/tests/Feature/ValidateInstancesCommandTest.php b/packages/Lvl0/FediDiscover/tests/Feature/ValidateInstancesCommandTest.php new file mode 100644 index 0000000..878d690 --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/ValidateInstancesCommandTest.php @@ -0,0 +1,221 @@ +artisan('fedi-discover:validate') + ->assertExitCode(0); + } + + public function test_it_exits_zero_when_all_instances_are_valid(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->assertExitCode(0); + } + + public function test_it_exits_nonzero_when_a_row_has_an_invalid_url(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'not-a-url', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->assertExitCode(1); + } + + public function test_it_exits_nonzero_when_a_row_has_a_zero_interval(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 0, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->assertExitCode(1); + } + + public function test_it_reports_summary_of_valid_and_invalid_counts(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://hachyderm.io', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'bogus', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->expectsOutputToContain('3') + ->expectsOutputToContain('2 valid') + ->expectsOutputToContain('1 invalid') + ->assertExitCode(1); + } + + public function test_it_does_not_fail_fast_and_reports_every_invalid_row(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'bogus-one', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $second = Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 0, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->expectsOutputToContain('bogus-one') + ->expectsOutputToContain((string) $second->id) + ->assertExitCode(1); + } + + public function test_it_includes_the_validation_error_message_for_each_invalid_row(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'not-a-url', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->expectsOutputToContain('Invalid URL: not-a-url') + ->assertExitCode(1); + } + + public function test_summary_counts_are_accurate_when_mixed(): void + { + // 2 valid + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://hachyderm.io', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + // 3 invalid (different defects) + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'bogus-one', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://fosstodon.org', + 'enabled' => true, + 'interval_seconds' => 0, + 'extras' => [], + ]); + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'also-bad', + 'enabled' => true, + 'interval_seconds' => -5, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate') + ->expectsOutputToContain('5') + ->expectsOutputToContain('2 valid') + ->expectsOutputToContain('3 invalid') + ->assertExitCode(1); + } + + public function test_it_exits_zero_with_enabled_only_when_no_enabled_instances_exist(): void + { + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => false, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate', ['--enabled-only' => true]) + ->assertExitCode(0); + } + + public function test_it_exits_zero_with_an_enabled_only_flag_when_disabled_rows_are_invalid(): void + { + // A disabled row that would fail InstanceConfig validation + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'broken-and-disabled', + 'enabled' => false, + 'interval_seconds' => 0, + 'extras' => [], + ]); + + // A valid enabled row + Instance::create([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + 'enabled' => true, + 'interval_seconds' => 600, + 'extras' => [], + ]); + + $this->artisan('fedi-discover:validate', ['--enabled-only' => true]) + ->assertExitCode(0); + } +}