3 - Add MastodonClient with HTTP-faked tests
This commit is contained in:
parent
e5ee0184b5
commit
fea8d48f6e
7 changed files with 235 additions and 20 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
34
packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php
Normal file
34
packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php
Normal 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']
|
||||
));
|
||||
}
|
||||
}
|
||||
165
packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php
Normal file
165
packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue