From 1b713e353998a58e79873b02fbfa8db0198f1d59 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 26 Apr 2026 00:42:21 +0200 Subject: [PATCH] 3 - Add LemmyClient with FediverseClientFactory dispatch --- .../src/Actions/PollFediverseAction.php | 11 +- .../src/Clients/FediverseClientFactory.php | 24 +++ .../FediDiscover/src/Clients/LemmyClient.php | 43 +++++ .../src/Clients/MastodonClient.php | 4 +- .../FediDiscover/src/Config/InstanceType.php | 1 + .../src/FediDiscoverServiceProvider.php | 6 +- .../FediversePost.php | 6 +- .../Feature/FediverseClientFactoryTest.php | 45 ++++++ .../tests/Feature/LemmyClientTest.php | 150 ++++++++++++++++++ .../tests/Feature/MastodonClientTest.php | 28 +++- .../tests/Feature/PollFediverseActionTest.php | 13 +- .../Feature/PollInstancesCommandTest.php | 12 +- 12 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 packages/Lvl0/FediDiscover/src/Clients/FediverseClientFactory.php create mode 100644 packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php rename packages/Lvl0/FediDiscover/src/{Clients => ValueObjects}/FediversePost.php (50%) create mode 100644 packages/Lvl0/FediDiscover/tests/Feature/FediverseClientFactoryTest.php create mode 100644 packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php diff --git a/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php b/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php index 9d0f75c..2c36212 100644 --- a/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php +++ b/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php @@ -4,18 +4,19 @@ namespace Lvl0\FediDiscover\Actions; -use Lvl0\FediDiscover\Clients\FediverseClientInterface; -use Lvl0\FediDiscover\Clients\FediversePost; +use Lvl0\FediDiscover\Clients\FediverseClientFactory; use Lvl0\FediDiscover\Events\UrlDiscovered; use Lvl0\FediDiscover\Models\Instance; +use Lvl0\FediDiscover\ValueObjects\FediversePost; class PollFediverseAction { - public function __construct(private FediverseClientInterface $client) {} + public function __construct(private FediverseClientFactory $factory) {} public function execute(Instance $instance): void { - $posts = $this->client->fetchPostsSince($instance, $instance->last_seen_id); + $client = $this->factory->for($instance); + $posts = $client->fetchPostsSince($instance, $instance->last_seen_id); $posts->each(function (FediversePost $post) use ($instance) { $this->processLinks($post, $instance); @@ -41,7 +42,7 @@ private function processLinks(FediversePost $post, Instance $instance): void return; } - $urls = collect($matches[0]) + collect($matches[0]) ->map(fn (string $u) => rtrim($u, '.,;:!?')) ->filter(fn (string $u) => filter_var($u, FILTER_VALIDATE_URL) !== false) ->filter(fn (string $u) => parse_url($u, PHP_URL_HOST) !== parse_url($instance->url, PHP_URL_HOST)) diff --git a/packages/Lvl0/FediDiscover/src/Clients/FediverseClientFactory.php b/packages/Lvl0/FediDiscover/src/Clients/FediverseClientFactory.php new file mode 100644 index 0000000..5cb96ca --- /dev/null +++ b/packages/Lvl0/FediDiscover/src/Clients/FediverseClientFactory.php @@ -0,0 +1,24 @@ +type) { + InstanceType::Mastodon => $this->mastodonClient, + InstanceType::Lemmy => $this->lemmyClient, + }; + } +} diff --git a/packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php b/packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php new file mode 100644 index 0000000..7551b08 --- /dev/null +++ b/packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php @@ -0,0 +1,43 @@ +url, PHP_URL_HOST) . '/api/v3/post/list'; + + $params = $lastSeenId !== null ? ['min_id' => $lastSeenId] : []; + + $response = Http::withHeaders([ + 'User-Agent' => config('fedi-discover.http.user_agent'), + ])->get($url, $params); + + if (! $response->successful() || ! is_array($response->json())) { + return collect(); + } + + return collect($response->json()['posts']) + ->map(fn (array $p) => $p['post']) + ->map(function (array $t) { + $parts = array_filter([$t['body'] ?? null, $t['url'] ?? null]); + $body = $parts ? implode(' ', $parts) : null; + + return new FediversePost( + cursorId: (string) $t['id'], + selfUrl: $t['ap_id'], + body: $body, + title: $t['name'], + publishedAt: $t['published'] + ); + }); + } +} diff --git a/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php b/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php index eedea0e..d6e58e5 100644 --- a/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php +++ b/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Lvl0\FediDiscover\Models\Instance; +use Lvl0\FediDiscover\ValueObjects\FediversePost; class MastodonClient implements FediverseClientInterface { @@ -28,7 +29,8 @@ public function fetchPostsSince(Instance $instance, ?string $lastSeenId): Collec ->map(fn (array $t) => new FediversePost( cursorId: $t['id'], selfUrl: $t['url'] ?? $t['uri'], - body: $t['content'] + body: $t['content'], + publishedAt: $t['created_at'] ?? null )); } } diff --git a/packages/Lvl0/FediDiscover/src/Config/InstanceType.php b/packages/Lvl0/FediDiscover/src/Config/InstanceType.php index fe70e64..b7c4fce 100644 --- a/packages/Lvl0/FediDiscover/src/Config/InstanceType.php +++ b/packages/Lvl0/FediDiscover/src/Config/InstanceType.php @@ -7,4 +7,5 @@ enum InstanceType: string { case Mastodon = 'mastodon'; + case Lemmy = 'lemmy'; } diff --git a/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php b/packages/Lvl0/FediDiscover/src/FediDiscoverServiceProvider.php index 9fcb44f..27cd9ec 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\Clients\FediverseClientFactory; use Lvl0\FediDiscover\Console\Commands\PollInstancesCommand; use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand; use Lvl0\FediDiscover\Events\UrlDiscovered; @@ -16,10 +17,7 @@ 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, - ); + $this->app->singleton(FediverseClientFactory::class); } public function boot(): void diff --git a/packages/Lvl0/FediDiscover/src/Clients/FediversePost.php b/packages/Lvl0/FediDiscover/src/ValueObjects/FediversePost.php similarity index 50% rename from packages/Lvl0/FediDiscover/src/Clients/FediversePost.php rename to packages/Lvl0/FediDiscover/src/ValueObjects/FediversePost.php index fa1c87e..e8d0423 100644 --- a/packages/Lvl0/FediDiscover/src/Clients/FediversePost.php +++ b/packages/Lvl0/FediDiscover/src/ValueObjects/FediversePost.php @@ -2,13 +2,15 @@ declare(strict_types=1); -namespace Lvl0\FediDiscover\Clients; +namespace Lvl0\FediDiscover\ValueObjects; class FediversePost { public function __construct( public string $cursorId, public string $selfUrl, - public ?string $body + public ?string $body = null, + public ?string $title = null, + public ?string $publishedAt = null, ) {} } diff --git a/packages/Lvl0/FediDiscover/tests/Feature/FediverseClientFactoryTest.php b/packages/Lvl0/FediDiscover/tests/Feature/FediverseClientFactoryTest.php new file mode 100644 index 0000000..06e278a --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/FediverseClientFactoryTest.php @@ -0,0 +1,45 @@ + InstanceType::Mastodon, 'url' => 'https://mastodon.social']); + + $client = $factory->for($instance); + + $this->assertInstanceOf(MastodonClient::class, $client); + } + + public function test_it_resolves_lemmy_client_for_lemmy_instance_type(): void + { + $factory = app(FediverseClientFactory::class); + + $instance = new Instance(['type' => InstanceType::Lemmy, 'url' => 'https://lemmy.world']); + + $client = $factory->for($instance); + + $this->assertInstanceOf(LemmyClient::class, $client); + } + + public function test_it_is_registered_as_a_singleton_in_the_container(): void + { + $a = $this->app->make(FediverseClientFactory::class); + $b = $this->app->make(FediverseClientFactory::class); + + $this->assertSame($a, $b); + } +} diff --git a/packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php b/packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php new file mode 100644 index 0000000..e06b6b1 --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php @@ -0,0 +1,150 @@ + Http::response([ + 'posts' => [ + $this->lemmyPost( + id: 42, + apId: 'https://lemmy.world/post/42', + name: 'My Great Post', + body: 'Some body text', + published: '2026-04-25T10:00:00.000000', + ), + ], + ], 200), + ]); + + $posts = (new LemmyClient)->fetchPostsSince($this->lemmyInstance(), null); + + $this->assertCount(1, $posts); + $this->assertInstanceOf(FediversePost::class, $posts->first()); + $this->assertSame('42', $posts->first()->cursorId); + $this->assertSame('https://lemmy.world/post/42', $posts->first()->selfUrl); + $this->assertSame('My Great Post', $posts->first()->title); + $this->assertSame('Some body text', $posts->first()->body); + $this->assertSame('2026-04-25T10:00:00.000000', $posts->first()->publishedAt); + } + + public function test_url_field_is_appended_to_body(): void + { + Http::fake([ + '*' => Http::response([ + 'posts' => [ + $this->lemmyPost( + id: 42, + apId: 'https://lemmy.world/post/42', + url: 'https://example-garden.blog/post-42', + body: 'Some original text.', + ), + ], + ], 200), + ]); + + $post = (new LemmyClient)->fetchPostsSince($this->lemmyInstance(), null)->first(); + + $this->assertStringContainsString('Some original text.', $post->body); + $this->assertStringContainsString('https://example-garden.blog/post-42', $post->body); + } + + public function test_url_absent_leaves_body_clean(): void + { + Http::fake([ + '*' => Http::response([ + 'posts' => [ + $this->lemmyPost( + id: 7, + apId: 'https://lemmy.world/post/7', + body: 'Just a regular post.', + ), + ], + ], 200), + ]); + + $post = (new LemmyClient)->fetchPostsSince($this->lemmyInstance(), null)->first(); + + $this->assertSame('Just a regular post.', $post->body); + } + + public function test_it_handles_posts_without_a_body_key(): void + { + Http::fake([ + '*' => Http::response([ + 'posts' => [ + [ + 'post' => [ + 'id' => 99, + 'ap_id' => 'https://lemmy.world/post/99', + 'url' => null, + 'name' => 'Link-only post', + 'published' => '2026-04-25T10:00:00.000000', + // 'body' key intentionally absent — real Lemmy API omits it for link-only posts + ], + ], + ], + ], 200), + ]); + + $post = (new LemmyClient)->fetchPostsSince($this->lemmyInstance(), null)->first(); + + $this->assertNull($post->body); + } + + public function test_it_hits_the_post_list_endpoint_of_the_instance(): void + { + Http::fake([ + 'lemmy.world/api/v3/post/list*' => Http::response(['posts' => []], 200), + ]); + + (new LemmyClient)->fetchPostsSince($this->lemmyInstance(), null); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://lemmy.world/api/v3/post/list') + && $request->method() === 'GET' + ); + } + + private function lemmyInstance(): Instance + { + return new Instance([ + 'type' => InstanceType::Lemmy, + 'url' => 'https://lemmy.world', + ]); + } + + /** + * @return array + */ + private function lemmyPost( + int $id, + string $apId, + ?string $url = null, + string $body = '', + string $name = 'A post title', + string $published = '2026-04-25T10:00:00.000000', + ): array { + return [ + 'post' => [ + 'id' => $id, + 'ap_id' => $apId, + 'url' => $url, + 'body' => $body, + 'name' => $name, + 'published' => $published, + ], + ]; + } +} diff --git a/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php b/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php index d7a56bf..0516bad 100644 --- a/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php +++ b/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php @@ -6,10 +6,10 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; -use Lvl0\FediDiscover\Clients\FediversePost; use Lvl0\FediDiscover\Clients\MastodonClient; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Models\Instance; +use Lvl0\FediDiscover\ValueObjects\FediversePost; use Tests\TestCase; class MastodonClientTest extends TestCase @@ -73,6 +73,32 @@ public function test_it_maps_each_status_to_a_fediverse_post(): void $this->assertSame('

Hello

', $posts->first()->body); } + public function test_it_maps_published_at_from_created_at(): void + { + Http::fake([ + '*' => Http::response([ + $this->mastodonStatus(id: '109876543210', url: 'https://mastodon.social/@alice/109876543210'), + ], 200), + ]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertSame('2026-04-25T10:00:00Z', $posts->first()->publishedAt); + } + + public function test_it_sets_title_to_null_for_mastodon_statuses(): void + { + Http::fake([ + '*' => Http::response([ + $this->mastodonStatus(id: '109876543210', url: 'https://mastodon.social/@alice/109876543210'), + ], 200), + ]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertNull($posts->first()->title); + } + public function test_it_falls_back_to_uri_when_url_is_null(): void { Http::fake([ diff --git a/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php b/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php index 9d52f77..697ee9d 100644 --- a/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php +++ b/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php @@ -7,11 +7,12 @@ 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\Clients\FediversePost; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Events\UrlDiscovered; use Lvl0\FediDiscover\Models\Instance; +use Lvl0\FediDiscover\ValueObjects\FediversePost; use Mockery; use Tests\TestCase; @@ -177,7 +178,10 @@ public function test_it_passes_the_existing_last_seen_id_to_the_client(): void ->with($instance, $instance->last_seen_id) ->andReturn(collect()); - (new PollFediverseAction($client))->execute($instance); + $factory = Mockery::mock(FediverseClientFactory::class); + $factory->shouldReceive('for')->with($instance)->andReturn($client); + + (new PollFediverseAction($factory))->execute($instance); } public function test_it_leaves_last_seen_id_unchanged_when_no_posts_are_returned(): void @@ -205,7 +209,10 @@ private function pollInstance(Instance $instance, array $posts): void $client = Mockery::mock(FediverseClientInterface::class); $client->shouldReceive('fetchPostsSince')->andReturn(collect($posts)); - (new PollFediverseAction($client))->execute($instance); + $factory = Mockery::mock(FediverseClientFactory::class); + $factory->shouldReceive('for')->andReturn($client); + + (new PollFediverseAction($factory))->execute($instance); } /** diff --git a/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php index 31ac841..f1797c7 100644 --- a/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php +++ b/packages/Lvl0/FediDiscover/tests/Feature/PollInstancesCommandTest.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Lvl0\FediDiscover\Actions\PollFediverseAction; +use Lvl0\FediDiscover\Clients\FediverseClientFactory; use Lvl0\FediDiscover\Clients\FediverseClientInterface; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Models\Instance; @@ -21,12 +22,15 @@ protected function setUp(): void { parent::setUp(); - // Bind a no-op stub so the command can resolve PollFediverseAction + // Bind a no-op factory 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()); + $clientStub = Mockery::mock(FediverseClientInterface::class); + $clientStub->shouldReceive('fetchPostsSince')->andReturn(collect()); - $this->app->bind(FediverseClientInterface::class, fn () => $stub); + $factoryStub = Mockery::mock(FediverseClientFactory::class); + $factoryStub->shouldReceive('for')->andReturn($clientStub); + + $this->app->instance(FediverseClientFactory::class, $factoryStub); } public function test_it_exits_zero_when_there_are_no_enabled_instances(): void