diff --git a/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php b/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php index 3200b87..9d0f75c 100644 --- a/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php +++ b/packages/Lvl0/FediDiscover/src/Actions/PollFediverseAction.php @@ -4,18 +4,18 @@ namespace Lvl0\FediDiscover\Actions; -use Lvl0\FediDiscover\Clients\FediverseClient; +use Lvl0\FediDiscover\Clients\FediverseClientInterface; use Lvl0\FediDiscover\Clients\FediversePost; use Lvl0\FediDiscover\Events\UrlDiscovered; use Lvl0\FediDiscover\Models\Instance; class PollFediverseAction { - public function __construct(private FediverseClient $client) {} + public function __construct(private FediverseClientInterface $client) {} public function execute(Instance $instance): void { - $posts = collect($this->client->fetchPostsSince($instance, $instance->last_seen_id)); + $posts = $this->client->fetchPostsSince($instance, $instance->last_seen_id); $posts->each(function (FediversePost $post) use ($instance) { $this->processLinks($post, $instance); diff --git a/packages/Lvl0/FediDiscover/src/Clients/FediverseClient.php b/packages/Lvl0/FediDiscover/src/Clients/FediverseClient.php deleted file mode 100644 index c69049d..0000000 --- a/packages/Lvl0/FediDiscover/src/Clients/FediverseClient.php +++ /dev/null @@ -1,12 +0,0 @@ - + */ + public function fetchPostsSince(Instance $instance, ?string $lastSeenId): Collection; +} diff --git a/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php b/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php new file mode 100644 index 0000000..eedea0e --- /dev/null +++ b/packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php @@ -0,0 +1,34 @@ +url, PHP_URL_HOST) . '/api/v1/timelines/public'; + + $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()) + ->map(fn (array $t) => new FediversePost( + cursorId: $t['id'], + selfUrl: $t['url'] ?? $t['uri'], + body: $t['content'] + )); + } +} diff --git a/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php b/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php new file mode 100644 index 0000000..d7a56bf --- /dev/null +++ b/packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php @@ -0,0 +1,165 @@ + Http::response([], 200), + ]); + + (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://mastodon.social/api/v1/timelines/public') + && $request->method() === 'GET' + ); + } + + public function test_it_omits_min_id_on_first_poll(): void + { + Http::fake(['*' => Http::response([], 200)]); + + (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + Http::assertSent(fn ($request) => ! str_contains($request->url(), 'min_id')); + } + + public function test_it_passes_min_id_on_subsequent_polls(): void + { + Http::fake(['*' => Http::response([], 200)]); + + (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), '109876543210'); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'min_id=109876543210')); + } + + public function test_it_returns_an_empty_collection_when_the_api_returns_no_posts(): void + { + Http::fake(['*' => Http::response([], 200)]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertTrue($posts->isEmpty()); + } + + public function test_it_maps_each_status_to_a_fediverse_post(): void + { + Http::fake([ + '*' => Http::response([ + $this->mastodonStatus(id: '109876543210', url: 'https://mastodon.social/@alice/109876543210', content: '

Hello

'), + $this->mastodonStatus(id: '109876543211', url: 'https://mastodon.social/@bob/109876543211', content: '

World

'), + ], 200), + ]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertCount(2, $posts); + $this->assertInstanceOf(FediversePost::class, $posts->first()); + $this->assertSame('109876543210', $posts->first()->cursorId); + $this->assertSame('https://mastodon.social/@alice/109876543210', $posts->first()->selfUrl); + $this->assertSame('

Hello

', $posts->first()->body); + } + + public function test_it_falls_back_to_uri_when_url_is_null(): void + { + Http::fake([ + '*' => Http::response([ + $this->mastodonStatus( + id: '109876543210', + url: null, + uri: 'https://hachyderm.io/users/bob/statuses/5678', + content: '

federated post

' + ), + ], 200), + ]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertSame('https://hachyderm.io/users/bob/statuses/5678', $posts->first()->selfUrl); + } + + public function test_it_preserves_newest_first_ordering_from_the_api(): void + { + Http::fake([ + '*' => Http::response([ + $this->mastodonStatus(id: '300', url: 'https://mastodon.social/@a/300'), + $this->mastodonStatus(id: '200', url: 'https://mastodon.social/@b/200'), + $this->mastodonStatus(id: '100', url: 'https://mastodon.social/@c/100'), + ], 200), + ]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertSame(['300', '200', '100'], $posts->pluck('cursorId')->all()); + } + + public function test_it_returns_an_empty_collection_on_a_non_2xx_response(): void + { + Http::fake(['*' => Http::response('Too many requests', 429)]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertTrue($posts->isEmpty()); + } + + public function test_it_returns_an_empty_collection_when_the_response_body_is_not_json(): void + { + Http::fake(['*' => Http::response('error', 200)]); + + $posts = (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $this->assertInstanceOf(Collection::class, $posts); + $this->assertTrue($posts->isEmpty()); + } + + public function test_it_sends_the_configured_user_agent(): void + { + Http::fake(['*' => Http::response([], 200)]); + + (new MastodonClient)->fetchPostsSince($this->mastodonInstance(), null); + + $expected = config('fedi-discover.http.user_agent'); + Http::assertSent(fn ($request) => $request->header('User-Agent')[0] === $expected); + } + + private function mastodonInstance(): Instance + { + return new Instance([ + 'type' => InstanceType::Mastodon, + 'url' => 'https://mastodon.social', + ]); + } + + /** + * @return array + */ + private function mastodonStatus( + string $id, + ?string $url = null, + ?string $uri = null, + string $content = '

example

', + ): array { + return [ + 'id' => $id, + 'url' => $url, + 'uri' => $uri ?? "https://mastodon.social/users/x/statuses/{$id}", + 'content' => $content, + 'created_at' => '2026-04-25T10:00:00Z', + 'account' => ['acct' => 'alice@mastodon.social'], + ]; + } +} diff --git a/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php b/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php index 48abc09..9d52f77 100644 --- a/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php +++ b/packages/Lvl0/FediDiscover/tests/Feature/PollFediverseActionTest.php @@ -7,7 +7,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Lvl0\FediDiscover\Actions\PollFediverseAction; -use Lvl0\FediDiscover\Clients\FediverseClient; +use Lvl0\FediDiscover\Clients\FediverseClientInterface; use Lvl0\FediDiscover\Clients\FediversePost; use Lvl0\FediDiscover\Config\InstanceType; use Lvl0\FediDiscover\Events\UrlDiscovered; @@ -171,11 +171,11 @@ public function test_it_passes_the_existing_last_seen_id_to_the_client(): void { $instance = $this->makeInstance(['last_seen_id' => '999']); - $client = Mockery::mock(FediverseClient::class); + $client = Mockery::mock(FediverseClientInterface::class); $client->shouldReceive('fetchPostsSince') ->once() ->with($instance, $instance->last_seen_id) - ->andReturn([]); + ->andReturn(collect()); (new PollFediverseAction($client))->execute($instance); } @@ -202,8 +202,8 @@ private function poll(array $posts, string $instanceUrl = 'https://mastodon.soci */ private function pollInstance(Instance $instance, array $posts): void { - $client = Mockery::mock(FediverseClient::class); - $client->shouldReceive('fetchPostsSince')->andReturn($posts); + $client = Mockery::mock(FediverseClientInterface::class); + $client->shouldReceive('fetchPostsSince')->andReturn(collect($posts)); (new PollFediverseAction($client))->execute($instance); } diff --git a/phpunit.xml b/phpunit.xml index 46d97dd..9ca208c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,6 +3,11 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" + processIsolation="false" + displayDetailsOnPhpunitDeprecations="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerNotices="true" > @@ -36,5 +41,7 @@ + +