3 - Add LemmyClient with FediverseClientFactory dispatch

This commit is contained in:
myrmidex 2026-04-26 00:42:21 +02:00
parent 1b652752e1
commit 1b713e3539
12 changed files with 323 additions and 20 deletions

View file

@ -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))

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Clients;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
class FediverseClientFactory
{
public function __construct(
private MastodonClient $mastodonClient,
private LemmyClient $lemmyClient,
) {}
public function for(Instance $instance): FediverseClientInterface
{
return match ($instance->type) {
InstanceType::Mastodon => $this->mastodonClient,
InstanceType::Lemmy => $this->lemmyClient,
};
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Clients;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Lvl0\FediDiscover\Models\Instance;
use Lvl0\FediDiscover\ValueObjects\FediversePost;
class LemmyClient implements FediverseClientInterface
{
public function fetchPostsSince(Instance $instance, ?string $lastSeenId): Collection
{
$url = 'https://' . parse_url($instance->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']
);
});
}
}

View file

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

View file

@ -7,4 +7,5 @@
enum InstanceType: string
{
case Mastodon = 'mastodon';
case Lemmy = 'lemmy';
}

View file

@ -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

View file

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

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
use Lvl0\FediDiscover\Clients\FediverseClientFactory;
use Lvl0\FediDiscover\Clients\LemmyClient;
use Lvl0\FediDiscover\Clients\MastodonClient;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Tests\TestCase;
class FediverseClientFactoryTest extends TestCase
{
public function test_it_resolves_mastodon_client_for_mastodon_instance_type(): void
{
$factory = app(FediverseClientFactory::class);
$instance = new Instance(['type' => 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);
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
use Illuminate\Support\Facades\Http;
use Lvl0\FediDiscover\Clients\LemmyClient;
use Lvl0\FediDiscover\Config\InstanceType;
use Lvl0\FediDiscover\Models\Instance;
use Lvl0\FediDiscover\ValueObjects\FediversePost;
use Tests\TestCase;
class LemmyClientTest extends TestCase
{
public function test_it_maps_each_post_to_a_fediverse_post(): void
{
Http::fake([
'*' => 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<string, mixed>
*/
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,
],
];
}
}

View file

@ -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('<p>Hello</p>', $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([

View file

@ -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);
}
/**

View file

@ -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