3 - Add MastodonClient with HTTP-faked tests

This commit is contained in:
myrmidex 2026-04-25 03:11:17 +02:00
parent e5ee0184b5
commit fea8d48f6e
7 changed files with 235 additions and 20 deletions

View file

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

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Clients;
use Lvl0\FediDiscover\Models\Instance;
interface FediverseClient
{
public function fetchPostsSince(Instance $instance, ?string $lastSeenId): array;
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Clients;
use Illuminate\Support\Collection;
use Lvl0\FediDiscover\Models\Instance;
interface FediverseClientInterface
{
/**
* Fetch posts newer than the given cursor.
*
* MUST return posts in newest-first order. Callers treat the
* first item as the new high-water mark.
*
* @return Collection<int, FediversePost>
*/
public function fetchPostsSince(Instance $instance, ?string $lastSeenId): Collection;
}

View file

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

View file

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Tests\Feature;
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 Tests\TestCase;
class MastodonClientTest extends TestCase
{
public function test_it_hits_the_public_timeline_endpoint_of_the_instance(): void
{
Http::fake([
'mastodon.social/api/v1/timelines/public*' => 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: '<p>Hello</p>'),
$this->mastodonStatus(id: '109876543211', url: 'https://mastodon.social/@bob/109876543211', content: '<p>World</p>'),
], 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('<p>Hello</p>', $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: '<p>federated post</p>'
),
], 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('<html>error</html>', 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<string, mixed>
*/
private function mastodonStatus(
string $id,
?string $url = null,
?string $uri = null,
string $content = '<p>example</p>',
): 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'],
];
}
}

View file

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

View file

@ -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"
>
<testsuites>
<testsuite name="Unit">
@ -36,5 +41,7 @@
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<ini name="display_errors" value="On"/>
<ini name="error_reporting" value="-1"/>
</php>
</phpunit>