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;
|
namespace Lvl0\FediDiscover\Actions;
|
||||||
|
|
||||||
use Lvl0\FediDiscover\Clients\FediverseClient;
|
use Lvl0\FediDiscover\Clients\FediverseClientInterface;
|
||||||
use Lvl0\FediDiscover\Clients\FediversePost;
|
use Lvl0\FediDiscover\Clients\FediversePost;
|
||||||
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
use Lvl0\FediDiscover\Models\Instance;
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
|
||||||
class PollFediverseAction
|
class PollFediverseAction
|
||||||
{
|
{
|
||||||
public function __construct(private FediverseClient $client) {}
|
public function __construct(private FediverseClientInterface $client) {}
|
||||||
|
|
||||||
public function execute(Instance $instance): void
|
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) {
|
$posts->each(function (FediversePost $post) use ($instance) {
|
||||||
$this->processLinks($post, $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\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Lvl0\FediDiscover\Actions\PollFediverseAction;
|
use Lvl0\FediDiscover\Actions\PollFediverseAction;
|
||||||
use Lvl0\FediDiscover\Clients\FediverseClient;
|
use Lvl0\FediDiscover\Clients\FediverseClientInterface;
|
||||||
use Lvl0\FediDiscover\Clients\FediversePost;
|
use Lvl0\FediDiscover\Clients\FediversePost;
|
||||||
use Lvl0\FediDiscover\Config\InstanceType;
|
use Lvl0\FediDiscover\Config\InstanceType;
|
||||||
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
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']);
|
$instance = $this->makeInstance(['last_seen_id' => '999']);
|
||||||
|
|
||||||
$client = Mockery::mock(FediverseClient::class);
|
$client = Mockery::mock(FediverseClientInterface::class);
|
||||||
$client->shouldReceive('fetchPostsSince')
|
$client->shouldReceive('fetchPostsSince')
|
||||||
->once()
|
->once()
|
||||||
->with($instance, $instance->last_seen_id)
|
->with($instance, $instance->last_seen_id)
|
||||||
->andReturn([]);
|
->andReturn(collect());
|
||||||
|
|
||||||
(new PollFediverseAction($client))->execute($instance);
|
(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
|
private function pollInstance(Instance $instance, array $posts): void
|
||||||
{
|
{
|
||||||
$client = Mockery::mock(FediverseClient::class);
|
$client = Mockery::mock(FediverseClientInterface::class);
|
||||||
$client->shouldReceive('fetchPostsSince')->andReturn($posts);
|
$client->shouldReceive('fetchPostsSince')->andReturn(collect($posts));
|
||||||
|
|
||||||
(new PollFediverseAction($client))->execute($instance);
|
(new PollFediverseAction($client))->execute($instance);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
bootstrap="vendor/autoload.php"
|
bootstrap="vendor/autoload.php"
|
||||||
colors="true"
|
colors="true"
|
||||||
|
processIsolation="false"
|
||||||
|
displayDetailsOnPhpunitDeprecations="true"
|
||||||
|
displayDetailsOnTestsThatTriggerErrors="true"
|
||||||
|
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||||
|
displayDetailsOnTestsThatTriggerNotices="true"
|
||||||
>
|
>
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
|
|
@ -36,5 +41,7 @@
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
<ini name="display_errors" value="On"/>
|
||||||
|
<ini name="error_reporting" value="-1"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue