3 - Add LemmyClient with FediverseClientFactory dispatch
This commit is contained in:
parent
1b652752e1
commit
1b713e3539
12 changed files with 323 additions and 20 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php
Normal file
43
packages/Lvl0/FediDiscover/src/Clients/LemmyClient.php
Normal 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']
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@
|
|||
enum InstanceType: string
|
||||
{
|
||||
case Mastodon = 'mastodon';
|
||||
case Lemmy = 'lemmy';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
150
packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php
Normal file
150
packages/Lvl0/FediDiscover/tests/Feature/LemmyClientTest.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue