2 - Add fedi-discover:validate console command

This commit is contained in:
myrmidex 2026-04-24 19:55:43 +02:00
parent fc1c8ba020
commit 52d6b493cb
6 changed files with 389 additions and 2 deletions

View file

@ -0,0 +1,64 @@
<?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\Models\Instance;
#[Signature('fedi-discover:validate {--enabled-only}')]
#[Description('Validate saved instances')]
class ValidateInstancesCommand extends Command
{
public function handle(): int
{
$instances = Instance::query();
if ($this->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;
}
}

View file

@ -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,
]);
}
}

View file

@ -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,
]);
}
}
}

View file

@ -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<string, mixed> $extras
* @property Carbon|null $last_polled_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Instance extends Model
{
/** @use HasFactory<InstanceFactory> */
@ -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);
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Config\InstanceConfig;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Tests\TestCase;
class InstanceConfigPersistenceTest extends TestCase
{
use RefreshDatabase;
public function test_instance_config_toArray_is_mass_assignable_on_the_model(): void
{
$config = InstanceConfig::fromArray([
'type' => 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);
}
}

View file

@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Tests\TestCase;
class ValidateInstancesCommandTest extends TestCase
{
use RefreshDatabase;
public function test_it_exits_zero_when_the_database_is_empty(): void
{
$this->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);
}
}