Compare commits
10 commits
3eff919945
...
6b610b699e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b610b699e | |||
| 3ad473f4a1 | |||
| 424ad2ff78 | |||
| bc535c8c0b | |||
| ec2113710a | |||
| 2cb86f3337 | |||
| 1b713e3539 | |||
| 1b652752e1 | |||
| fea8d48f6e | |||
| e5ee0184b5 |
31 changed files with 1621 additions and 43 deletions
12
app/Enums/PageStatusEnum.php
Normal file
12
app/Enums/PageStatusEnum.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PageStatusEnum: string
|
||||||
|
{
|
||||||
|
case Discovered = 'discovered';
|
||||||
|
case Fetched = 'fetched';
|
||||||
|
case Failed = 'failed';
|
||||||
|
}
|
||||||
39
app/Listeners/UrlDiscoveredListener.php
Normal file
39
app/Listeners/UrlDiscoveredListener.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\PageLink;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
|
|
||||||
|
class UrlDiscoveredListener implements ShouldQueue
|
||||||
|
{
|
||||||
|
public function handle(UrlDiscovered $event): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($event) {
|
||||||
|
$targetPage = Page::firstOrCreate(
|
||||||
|
['url' => $event->url],
|
||||||
|
['status' => PageStatusEnum::Discovered, 'instance_id' => $event->instanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($event->postUrl === null || $event->postUrl === $event->url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePage = Page::firstOrCreate(
|
||||||
|
['url' => $event->postUrl],
|
||||||
|
['status' => PageStatusEnum::Discovered, 'instance_id' => $event->instanceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
PageLink::firstOrCreate([
|
||||||
|
'source_page_id' => $sourcePage->id,
|
||||||
|
'target_page_id' => $targetPage->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/Page.php
Normal file
51
app/Models/Page.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
|
use Database\Factories\PageFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
|
||||||
|
class Page extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PageFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'url',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
'instance_id',
|
||||||
|
'posted_at',
|
||||||
|
'fetched_at',
|
||||||
|
'failed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'status' => PageStatusEnum::class,
|
||||||
|
'posted_at' => 'datetime',
|
||||||
|
'fetched_at' => 'datetime',
|
||||||
|
'failed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function instance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Instance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function outgoingLinks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PageLink::class, 'source_page_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incomingLinks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PageLink::class, 'target_page_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/PageLink.php
Normal file
31
app/Models/PageLink.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\PageLinkFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PageLink extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PageLinkFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'source_page_id',
|
||||||
|
'target_page_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sourcePage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Page::class, 'source_page_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function targetPage(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Page::class, 'target_page_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\UrlDiscoveredListener;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +22,6 @@ public function register(): void
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Event::listen(UrlDiscovered::class, UrlDiscoveredListener::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
|
@ -11,14 +14,13 @@
|
||||||
class PageFactory extends Factory
|
class PageFactory extends Factory
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'url' => fake()->url(),
|
||||||
|
'status' => PageStatusEnum::Discovered,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
database/factories/PageLinkFactory.php
Normal file
34
database/factories/PageLinkFactory.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\PageLink;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<PageLink>
|
||||||
|
*/
|
||||||
|
class PageLinkFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withSource(Page $page): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'source_page_id' => $page->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withTarget(Page $page): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'target_page_id' => $page->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
database/migrations/2026_04_25_234157_create_pages_table.php
Normal file
34
database/migrations/2026_04_25_234157_create_pages_table.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->text('url')->unique();
|
||||||
|
$table->string('status')->default(PageStatusEnum::Discovered->value)->index();
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->foreignId('instance_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('fedi_discover_instances')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->timestampTz('posted_at')->nullable();
|
||||||
|
$table->timestampTz('fetched_at')->nullable();
|
||||||
|
$table->timestampTz('failed_at')->nullable();
|
||||||
|
$table->timestampsTz();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('page_links', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('source_page_id')->constrained('pages');
|
||||||
|
$table->foreignId('target_page_id')->constrained('pages');
|
||||||
|
$table->timestampsTz();
|
||||||
|
|
||||||
|
$table->unique(['source_page_id', 'target_page_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('page_links');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Actions;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Lvl0\FediDiscover\Clients\FediverseClientFactory;
|
||||||
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Lvl0\FediDiscover\ValueObjects\FediversePost;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PollFediverseAction
|
||||||
|
{
|
||||||
|
public function __construct(private FediverseClientFactory $factory) {}
|
||||||
|
|
||||||
|
public function execute(Instance $instance): void
|
||||||
|
{
|
||||||
|
$client = $this->factory->for($instance);
|
||||||
|
$posts = $client->fetchPostsSince($instance, $instance->last_seen_id);
|
||||||
|
|
||||||
|
$posts->each(function (FediversePost $post) use ($instance) {
|
||||||
|
try {
|
||||||
|
$this->processLinks($post, $instance);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('fedi-discover:processLinks failed', [
|
||||||
|
'instance_id' => $instance->id,
|
||||||
|
'instance_url' => $instance->url,
|
||||||
|
'post_url' => $post->selfUrl,
|
||||||
|
'exception' => $e::class,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($posts->isNotEmpty()) {
|
||||||
|
$instance->last_seen_id = $posts->first()->cursorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instance->last_polled_at = now();
|
||||||
|
$instance->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processLinks(FediversePost $post, Instance $instance): void
|
||||||
|
{
|
||||||
|
if ($post->body === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$linksFound = preg_match_all('~https?://[^\s<>"\'()\[\]]+~', $post->body, $matches);
|
||||||
|
|
||||||
|
if ($linksFound === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
->unique()
|
||||||
|
->each(fn (string $url) => UrlDiscovered::dispatch(
|
||||||
|
url: $url,
|
||||||
|
instanceId: $instance->id,
|
||||||
|
discoveredAt: CarbonImmutable::now(),
|
||||||
|
postUrl: $post->selfUrl,
|
||||||
|
postBody: $post->body,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Clients;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Lvl0\FediDiscover\ValueObjects\FediversePost;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
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'),
|
||||||
|
])->timeout(config('fedi-discover.http.timeout'))->get($url, $params);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php
Normal file
36
packages/Lvl0/FediDiscover/src/Clients/MastodonClient.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?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 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'),
|
||||||
|
])->timeout(config('fedi-discover.http.timeout'))->get($url, $params);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->json() ?? [])
|
||||||
|
->map(fn (array $t) => new FediversePost(
|
||||||
|
cursorId: $t['id'],
|
||||||
|
selfUrl: $t['url'] ?? $t['uri'] ?? null,
|
||||||
|
body: $t['content'],
|
||||||
|
publishedAt: $t['created_at'] ?? null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,4 +7,5 @@
|
||||||
enum InstanceType: string
|
enum InstanceType: string
|
||||||
{
|
{
|
||||||
case Mastodon = 'mastodon';
|
case Mastodon = 'mastodon';
|
||||||
|
case Lemmy = 'lemmy';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Lvl0\FediDiscover\Actions\PollFediverseAction;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('fedi-discover:poll')]
|
||||||
|
#[Description('Poll all enabled fediverse instances for new URLs')]
|
||||||
|
class PollInstancesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PollFediverseAction $action
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$hadFailure = false;
|
||||||
|
|
||||||
|
Instance::enabled()
|
||||||
|
->get()
|
||||||
|
->each(function (Instance $instance) use (&$hadFailure) {
|
||||||
|
try {
|
||||||
|
$this->action->execute($instance);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error("Failed to poll {$instance->url}: {$e->getMessage()}");
|
||||||
|
Log::warning('fedi-discover:poll failed', [
|
||||||
|
'instance_id' => $instance->id,
|
||||||
|
'instance_url' => $instance->url,
|
||||||
|
'exception' => $e::class,
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$hadFailure = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($hadFailure) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,22 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Lvl0\FediDiscover\Events;
|
namespace Lvl0\FediDiscover\Events;
|
||||||
|
|
||||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Broadcasting\PrivateChannel;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class UrlDiscovered
|
class UrlDiscovered
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $url,
|
public string $url,
|
||||||
public ?string $postUrl,
|
public int $instanceId,
|
||||||
public ?string $postBody
|
public CarbonImmutable $discoveredAt,
|
||||||
|
public ?string $postUrl = null,
|
||||||
|
public ?string $postBody = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
new PrivateChannel('discovery'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,32 +4,31 @@
|
||||||
|
|
||||||
namespace Lvl0\FediDiscover;
|
namespace Lvl0\FediDiscover;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Lvl0\FediDiscover\Clients\FediverseClientFactory;
|
||||||
|
use Lvl0\FediDiscover\Console\Commands\PollInstancesCommand;
|
||||||
use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand;
|
use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand;
|
||||||
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
|
||||||
|
|
||||||
class FediDiscoverServiceProvider extends ServiceProvider
|
class FediDiscoverServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->mergeConfigFrom(__DIR__ . '/../config/fedi-discover.php', 'fedi-discover');
|
$this->mergeConfigFrom(__DIR__ . '/../config/fedi-discover.php', 'fedi-discover');
|
||||||
|
|
||||||
|
$this->app->singleton(FediverseClientFactory::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
|
||||||
Event::listen(
|
|
||||||
UrlDiscovered::class,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../config/fedi-discover.php' => config_path('fedi-discover.php'),
|
__DIR__ . '/../config/fedi-discover.php' => config_path('fedi-discover.php'),
|
||||||
], 'fedi-discover-config');
|
], 'fedi-discover-config');
|
||||||
|
|
||||||
$this->commands([
|
$this->commands([
|
||||||
|
PollInstancesCommand::class,
|
||||||
ValidateInstancesCommand::class,
|
ValidateInstancesCommand::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
* @property bool $enabled
|
* @property bool $enabled
|
||||||
* @property int $interval_seconds
|
* @property int $interval_seconds
|
||||||
* @property array<string, mixed> $extras
|
* @property array<string, mixed> $extras
|
||||||
* @property int $last_seen_id
|
* @property string|null $last_seen_id
|
||||||
* @property Carbon|null $last_polled_at
|
* @property Carbon|null $last_polled_at
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
|
|
@ -40,7 +40,11 @@ class Instance extends Model
|
||||||
'last_polled_at' => 'datetime',
|
'last_polled_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scopeEnabled($query): Builder
|
/**
|
||||||
|
* @param Builder<self> $query
|
||||||
|
* @return Builder<self>
|
||||||
|
*/
|
||||||
|
public function scopeEnabled(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('enabled', true);
|
return $query->where('enabled', true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\ValueObjects;
|
||||||
|
|
||||||
|
class FediversePost
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $cursorId,
|
||||||
|
public ?string $selfUrl,
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
191
packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php
Normal file
191
packages/Lvl0/FediDiscover/tests/Feature/MastodonClientTest.php
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Tests\Feature;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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_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([
|
||||||
|
'*' => 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Tests\Feature;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
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\Config\InstanceType;
|
||||||
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Lvl0\FediDiscover\ValueObjects\FediversePost;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PollFediverseActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_fires_one_event_per_extracted_url(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'See https://example.com/one and https://other.example/two'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://example.com/one');
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://other.example/two');
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_extracts_urls_from_html_anchor_tags(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', '<p>Check <a href="https://example.com/article">this</a>!</p>'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://example.com/article');
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_extracts_urls_from_markdown_links(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll(
|
||||||
|
posts: [new FediversePost('1', 'https://lemmy.world/post/42', 'A [great article](https://example.com/article) about trees.')],
|
||||||
|
instanceUrl: 'https://lemmy.world',
|
||||||
|
);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://example.com/article');
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_strips_trailing_punctuation_from_urls(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'Check https://example.com/article, it is great. Also https://other.example/page.'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://example.com/article');
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://other.example/page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_deduplicates_urls_within_a_single_post(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'Here is https://example.com/article and again https://example.com/article'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_filters_urls_on_the_polling_instance_host(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'See https://mastodon.social/@bob/42 and https://example.com/article'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->url === 'https://example.com/article');
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_ignores_posts_with_a_null_body(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertNotDispatched(UrlDiscovered::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_ignores_non_http_schemes(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'Email mailto:alice@example.com or try ftp://files.example.com/x'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertNotDispatched(UrlDiscovered::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_passes_post_self_url_and_body_through_to_the_event(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$body = 'Here is https://example.com/article with surrounding context.';
|
||||||
|
|
||||||
|
$this->pollInstance($instance, [
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', $body),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatched(UrlDiscovered::class, fn (UrlDiscovered $e) => $e->postUrl === 'https://mastodon.social/@alice/1'
|
||||||
|
&& $e->postBody === $body
|
||||||
|
&& $e->instanceId === $instance->id
|
||||||
|
&& $e->discoveredAt instanceof CarbonImmutable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_processes_multiple_posts(): void
|
||||||
|
{
|
||||||
|
Event::fake([UrlDiscovered::class]);
|
||||||
|
|
||||||
|
$this->poll([
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'See https://example.com/one'),
|
||||||
|
new FediversePost('2', 'https://mastodon.social/@bob/2', 'Also https://example.com/two'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Event::assertDispatchedTimes(UrlDiscovered::class, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_updates_last_seen_id_to_the_first_posts_cursor(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
|
||||||
|
// Clients return newest-first; the action treats posts[0]
|
||||||
|
// as the new high-water mark without inspecting cursor values.
|
||||||
|
$this->pollInstance($instance, [
|
||||||
|
new FediversePost('newest-cursor', 'https://mastodon.social/@alice/3', 'x'),
|
||||||
|
new FediversePost('middle-cursor', 'https://mastodon.social/@bob/2', 'y'),
|
||||||
|
new FediversePost('oldest-cursor', 'https://mastodon.social/@carol/1', 'z'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('newest-cursor', $instance->fresh()->last_seen_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_updates_last_polled_at(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$this->assertNull($instance->last_polled_at);
|
||||||
|
|
||||||
|
$this->pollInstance($instance, [
|
||||||
|
new FediversePost('1', 'https://mastodon.social/@alice/1', 'x'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull($instance->fresh()->last_polled_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_passes_the_existing_last_seen_id_to_the_client(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance(['last_seen_id' => '999']);
|
||||||
|
|
||||||
|
$client = Mockery::mock(FediverseClientInterface::class);
|
||||||
|
$client->shouldReceive('fetchPostsSince')
|
||||||
|
->once()
|
||||||
|
->with($instance, $instance->last_seen_id)
|
||||||
|
->andReturn(collect());
|
||||||
|
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance(['last_seen_id' => '500']);
|
||||||
|
|
||||||
|
$this->pollInstance($instance, []);
|
||||||
|
|
||||||
|
$this->assertSame('500', $instance->fresh()->last_seen_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<FediversePost> $posts
|
||||||
|
*/
|
||||||
|
private function poll(array $posts, string $instanceUrl = 'https://mastodon.social'): void
|
||||||
|
{
|
||||||
|
$this->pollInstance($this->makeInstance(['url' => $instanceUrl]), $posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<FediversePost> $posts
|
||||||
|
*/
|
||||||
|
private function pollInstance(Instance $instance, array $posts): void
|
||||||
|
{
|
||||||
|
$client = Mockery::mock(FediverseClientInterface::class);
|
||||||
|
$client->shouldReceive('fetchPostsSince')->andReturn(collect($posts));
|
||||||
|
|
||||||
|
$factory = Mockery::mock(FediverseClientFactory::class);
|
||||||
|
$factory->shouldReceive('for')->andReturn($client);
|
||||||
|
|
||||||
|
(new PollFediverseAction($factory))->execute($instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
*/
|
||||||
|
private function makeInstance(array $overrides = []): Instance
|
||||||
|
{
|
||||||
|
return Instance::create(array_merge([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://mastodon.social',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Lvl0\FediDiscover\Tests\Feature;
|
||||||
|
|
||||||
|
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;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PollInstancesCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Bind a no-op factory stub so the command can resolve PollFediverseAction
|
||||||
|
// from the container without making real HTTP calls.
|
||||||
|
$clientStub = Mockery::mock(FediverseClientInterface::class);
|
||||||
|
$clientStub->shouldReceive('fetchPostsSince')->andReturn(collect());
|
||||||
|
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$this->artisan('fedi-discover:poll')
|
||||||
|
->assertExitCode(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_calls_the_action_for_each_enabled_instance_and_skips_disabled(): void
|
||||||
|
{
|
||||||
|
$enabled1 = Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://mastodon.social',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$enabled2 = Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://fosstodon.org',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://disabled.example',
|
||||||
|
'enabled' => false,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$calledWith = [];
|
||||||
|
|
||||||
|
$action = Mockery::mock(PollFediverseAction::class);
|
||||||
|
$action->shouldReceive('execute')
|
||||||
|
->twice()
|
||||||
|
->withArgs(function (Instance $instance) use (&$calledWith): bool {
|
||||||
|
$calledWith[] = $instance->url;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->app->instance(PollFediverseAction::class, $action);
|
||||||
|
|
||||||
|
$this->artisan('fedi-discover:poll')->assertExitCode(0);
|
||||||
|
|
||||||
|
$this->assertEqualsCanonicalizing(
|
||||||
|
[$enabled1->url, $enabled2->url],
|
||||||
|
$calledWith,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_one_instance_throwing_does_not_stop_remaining_instances_from_being_polled(): void
|
||||||
|
{
|
||||||
|
Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://failing.example',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$healthy = Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://healthy.example',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$calledWith = [];
|
||||||
|
|
||||||
|
$action = Mockery::mock(PollFediverseAction::class);
|
||||||
|
$action->shouldReceive('execute')
|
||||||
|
->twice()
|
||||||
|
->andReturnUsing(function (Instance $instance) use (&$calledWith): void {
|
||||||
|
$calledWith[] = $instance->url;
|
||||||
|
|
||||||
|
if ($instance->url === 'https://failing.example') {
|
||||||
|
throw new RuntimeException('Connection refused');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->app->instance(PollFediverseAction::class, $action);
|
||||||
|
|
||||||
|
$this->artisan('fedi-discover:poll')->assertExitCode(1);
|
||||||
|
|
||||||
|
$this->assertEqualsCanonicalizing(
|
||||||
|
['https://failing.example', $healthy->url],
|
||||||
|
$calledWith,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_exits_one_when_at_least_one_instance_fails(): void
|
||||||
|
{
|
||||||
|
Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://failing.example',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Instance::create([
|
||||||
|
'type' => InstanceType::Mastodon,
|
||||||
|
'url' => 'https://healthy.example',
|
||||||
|
'enabled' => true,
|
||||||
|
'interval_seconds' => 600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$action = Mockery::mock(PollFediverseAction::class);
|
||||||
|
$action->shouldReceive('execute')
|
||||||
|
->twice()
|
||||||
|
->andReturnUsing(function (Instance $instance): void {
|
||||||
|
if ($instance->url === 'https://failing.example') {
|
||||||
|
throw new RuntimeException('Connection refused');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->app->instance(PollFediverseAction::class, $action);
|
||||||
|
|
||||||
|
$this->artisan('fedi-discover:poll')->assertExitCode(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
namespace Lvl0\FediDiscover\Tests\Unit;
|
namespace Lvl0\FediDiscover\Tests\Unit;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
|
@ -11,13 +12,19 @@ class UrlDiscoveredTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_it_exposes_all_payload_fields(): void
|
public function test_it_exposes_all_payload_fields(): void
|
||||||
{
|
{
|
||||||
|
$discoveredAt = CarbonImmutable::parse('2026-04-26T12:00:00');
|
||||||
|
|
||||||
$event = new UrlDiscovered(
|
$event = new UrlDiscovered(
|
||||||
url: 'https://example.com/article',
|
url: 'https://example.com/article',
|
||||||
|
instanceId: 42,
|
||||||
|
discoveredAt: $discoveredAt,
|
||||||
postUrl: 'https://mastodon.social/@alice/109876543210',
|
postUrl: 'https://mastodon.social/@alice/109876543210',
|
||||||
postBody: 'Check out this article: https://example.com/article'
|
postBody: 'Check out this article: https://example.com/article'
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertSame('https://example.com/article', $event->url);
|
$this->assertSame('https://example.com/article', $event->url);
|
||||||
|
$this->assertSame(42, $event->instanceId);
|
||||||
|
$this->assertTrue($discoveredAt->eq($event->discoveredAt));
|
||||||
$this->assertSame('https://mastodon.social/@alice/109876543210', $event->postUrl);
|
$this->assertSame('https://mastodon.social/@alice/109876543210', $event->postUrl);
|
||||||
$this->assertSame('Check out this article: https://example.com/article', $event->postBody);
|
$this->assertSame('Check out this article: https://example.com/article', $event->postBody);
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +33,8 @@ public function test_post_body_is_nullable(): void
|
||||||
{
|
{
|
||||||
$event = new UrlDiscovered(
|
$event = new UrlDiscovered(
|
||||||
url: 'https://example.com/article',
|
url: 'https://example.com/article',
|
||||||
|
instanceId: 1,
|
||||||
|
discoveredAt: CarbonImmutable::parse('2026-04-26T12:00:00'),
|
||||||
postUrl: 'https://mastodon.social/@alice/109876543210',
|
postUrl: 'https://mastodon.social/@alice/109876543210',
|
||||||
postBody: null
|
postBody: null
|
||||||
);
|
);
|
||||||
|
|
|
||||||
35
phpunit.xml
35
phpunit.xml
|
|
@ -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">
|
||||||
|
|
@ -22,19 +27,21 @@
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<server name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<server name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<server name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<server name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<server name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
<server name="DB_CONNECTION" value="sqlite"/>
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
<server name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="DB_URL" value=""/>
|
<server name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<server name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<server name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<server name="SESSION_DRIVER" value="array"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<server name="PULSE_ENABLED" value="false"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<server name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<server name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
<ini name="display_errors" value="On"/>
|
||||||
|
<ini name="error_reporting" value="-1"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Schedule::command('fedi-discover:poll')
|
||||||
$this->comment(Inspiring::quote());
|
->everyMinute()
|
||||||
})->purpose('Display an inspiring quote');
|
->withoutOverlapping(5)
|
||||||
|
->runInBackground();
|
||||||
|
|
|
||||||
139
tests/Feature/UrlDiscoveryTest.php
Normal file
139
tests/Feature/UrlDiscoveryTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
|
use App\Listeners\UrlDiscoveredListener;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\PageLink;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Events\CallQueuedListener;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Lvl0\FediDiscover\Config\InstanceType;
|
||||||
|
use Lvl0\FediDiscover\Events\UrlDiscovered;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UrlDiscoveryTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private function makeInstance(): Instance
|
||||||
|
{
|
||||||
|
return Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Instance $instance, array $overrides = []): UrlDiscovered
|
||||||
|
{
|
||||||
|
return new UrlDiscovered(
|
||||||
|
url: $overrides['url'] ?? 'https://example-blog.com/article',
|
||||||
|
instanceId: $overrides['instanceId'] ?? $instance->id,
|
||||||
|
discoveredAt: $overrides['discoveredAt'] ?? CarbonImmutable::parse('2026-04-26T12:00:00Z'),
|
||||||
|
postUrl: array_key_exists('postUrl', $overrides) ? $overrides['postUrl'] : 'https://mastodon.social/@alice/109876543210',
|
||||||
|
postBody: array_key_exists('postBody', $overrides) ? $overrides['postBody'] : 'check this out https://example-blog.com/article',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 9 — happy path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_listener_creates_target_page_and_source_page_with_link(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$discoveredAt = CarbonImmutable::parse('2026-04-26T12:00:00Z');
|
||||||
|
|
||||||
|
$event = new UrlDiscovered(
|
||||||
|
url: 'https://example-blog.com/article',
|
||||||
|
instanceId: $instance->id,
|
||||||
|
discoveredAt: $discoveredAt,
|
||||||
|
postUrl: 'https://mastodon.social/@alice/109876543210',
|
||||||
|
postBody: 'check this out https://example-blog.com/article',
|
||||||
|
);
|
||||||
|
|
||||||
|
event($event);
|
||||||
|
|
||||||
|
// Target page
|
||||||
|
$targetPage = Page::where('url', 'https://example-blog.com/article')->first();
|
||||||
|
$this->assertNotNull($targetPage);
|
||||||
|
$this->assertSame(PageStatusEnum::Discovered, $targetPage->status);
|
||||||
|
$this->assertSame($instance->id, $targetPage->instance_id);
|
||||||
|
|
||||||
|
// Source page
|
||||||
|
$sourcePage = Page::where('url', 'https://mastodon.social/@alice/109876543210')->first();
|
||||||
|
$this->assertNotNull($sourcePage);
|
||||||
|
$this->assertSame(PageStatusEnum::Discovered, $sourcePage->status);
|
||||||
|
$this->assertSame($instance->id, $sourcePage->instance_id);
|
||||||
|
$this->assertNull($sourcePage->fetched_at);
|
||||||
|
|
||||||
|
// Edge
|
||||||
|
$link = PageLink::where('source_page_id', $sourcePage->id)
|
||||||
|
->where('target_page_id', $targetPage->id)
|
||||||
|
->first();
|
||||||
|
$this->assertNotNull($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 10 — idempotency
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_listener_is_idempotent_on_repeated_event(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$event = $this->makeEvent($instance);
|
||||||
|
|
||||||
|
event($event);
|
||||||
|
event($event);
|
||||||
|
|
||||||
|
$this->assertSame(2, Page::count());
|
||||||
|
$this->assertSame(1, PageLink::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 11 — null postUrl: only target page, no edge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_listener_with_null_post_url_creates_only_target_page(): void
|
||||||
|
{
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$event = $this->makeEvent($instance, ['postUrl' => null, 'postBody' => null]);
|
||||||
|
|
||||||
|
event($event);
|
||||||
|
|
||||||
|
$this->assertSame(1, Page::count());
|
||||||
|
$this->assertSame(0, PageLink::count());
|
||||||
|
|
||||||
|
$targetPage = Page::where('url', 'https://example-blog.com/article')->first();
|
||||||
|
$this->assertNotNull($targetPage);
|
||||||
|
$this->assertSame(PageStatusEnum::Discovered, $targetPage->status);
|
||||||
|
$this->assertSame($instance->id, $targetPage->instance_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 12 — listener is queued, not run inline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function test_listener_is_pushed_to_queue_not_run_inline(): void
|
||||||
|
{
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$instance = $this->makeInstance();
|
||||||
|
$event = $this->makeEvent($instance);
|
||||||
|
|
||||||
|
event($event);
|
||||||
|
|
||||||
|
Queue::assertPushed(CallQueuedListener::class, function (CallQueuedListener $job): bool {
|
||||||
|
return $job->class === UrlDiscoveredListener::class;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/Unit/Models/PageLinkTest.php
Normal file
52
tests/Unit/Models/PageLinkTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\PageLink;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PageLinkTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_page_link_model_fillable_fields_and_relationships(): void
|
||||||
|
{
|
||||||
|
$source = Page::factory()->create(['url' => 'https://source.example.com/post/1']);
|
||||||
|
$target = Page::factory()->create(['url' => 'https://target.example.com/page/2']);
|
||||||
|
|
||||||
|
$link = PageLink::create([
|
||||||
|
'source_page_id' => $source->id,
|
||||||
|
'target_page_id' => $target->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $link->fresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($fresh);
|
||||||
|
$this->assertSame($source->id, $fresh->source_page_id);
|
||||||
|
$this->assertSame($target->id, $fresh->target_page_id);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Page::class, $fresh->sourcePage);
|
||||||
|
$this->assertSame($source->id, $fresh->sourcePage->id);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Page::class, $fresh->targetPage);
|
||||||
|
$this->assertSame($target->id, $fresh->targetPage->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_page_link_factory_with_source_and_target_methods_create_a_link(): void
|
||||||
|
{
|
||||||
|
$source = Page::factory()->create(['url' => 'https://source.example.com/post/1']);
|
||||||
|
$target = Page::factory()->create(['url' => 'https://target.example.com/page/2']);
|
||||||
|
|
||||||
|
$link = PageLink::factory()
|
||||||
|
->withSource($source)
|
||||||
|
->withTarget($target)
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->assertSame($source->id, $link->source_page_id);
|
||||||
|
$this->assertSame($target->id, $link->target_page_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
tests/Unit/Models/PageTest.php
Normal file
99
tests/Unit/Models/PageTest.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
|
use App\Enums\PageStatusEnum;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\PageLink;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Lvl0\FediDiscover\Config\InstanceType;
|
||||||
|
use Lvl0\FediDiscover\Models\Instance;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_page_model_fillable_fields_can_be_mass_assigned(): void
|
||||||
|
{
|
||||||
|
$page = Page::create([
|
||||||
|
'url' => 'https://example.com/article',
|
||||||
|
'status' => 'discovered',
|
||||||
|
'title' => 'An Example Article',
|
||||||
|
'instance_id' => null,
|
||||||
|
'posted_at' => null,
|
||||||
|
'fetched_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $page->fresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($fresh);
|
||||||
|
$this->assertSame('https://example.com/article', $fresh->url);
|
||||||
|
$this->assertSame('An Example Article', $fresh->title);
|
||||||
|
$this->assertNull($fresh->instance_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_page_instance_relationship_returns_the_owning_instance(): void
|
||||||
|
{
|
||||||
|
$instance = Instance::factory()
|
||||||
|
->type(InstanceType::Mastodon)
|
||||||
|
->enabled()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$page = Page::create([
|
||||||
|
'url' => 'https://example.com/post/1',
|
||||||
|
'status' => 'discovered',
|
||||||
|
'instance_id' => $instance->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $page->fresh();
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Instance::class, $fresh->instance);
|
||||||
|
$this->assertSame($instance->id, $fresh->instance->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_page_outgoing_and_incoming_links_relationships(): void
|
||||||
|
{
|
||||||
|
$source = Page::factory()->create(['url' => 'https://example.com/source']);
|
||||||
|
$target = Page::factory()->create(['url' => 'https://example.com/target']);
|
||||||
|
|
||||||
|
PageLink::create([
|
||||||
|
'source_page_id' => $source->id,
|
||||||
|
'target_page_id' => $target->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$freshSource = $source->fresh();
|
||||||
|
$freshTarget = $target->fresh();
|
||||||
|
|
||||||
|
$this->assertCount(1, $freshSource->outgoingLinks);
|
||||||
|
$this->assertCount(0, $freshSource->incomingLinks);
|
||||||
|
$this->assertCount(1, $freshTarget->incomingLinks);
|
||||||
|
$this->assertCount(0, $freshTarget->outgoingLinks);
|
||||||
|
|
||||||
|
$this->assertSame($source->id, $freshTarget->incomingLinks->first()->source_page_id);
|
||||||
|
$this->assertSame($target->id, $freshSource->outgoingLinks->first()->target_page_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_page_status_is_cast_to_enum(): void
|
||||||
|
{
|
||||||
|
$cases = [
|
||||||
|
['string' => 'discovered', 'enum' => PageStatusEnum::Discovered],
|
||||||
|
['string' => 'fetched', 'enum' => PageStatusEnum::Fetched],
|
||||||
|
['string' => 'failed', 'enum' => PageStatusEnum::Failed],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as ['string' => $raw, 'enum' => $expected]) {
|
||||||
|
$page = Page::create([
|
||||||
|
'url' => 'https://example.com/' . $raw,
|
||||||
|
'status' => $raw,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fresh = $page->fresh();
|
||||||
|
|
||||||
|
$this->assertInstanceOf(PageStatusEnum::class, $fresh->status, "status '{$raw}' should cast to PageStatusEnum");
|
||||||
|
$this->assertSame($expected, $fresh->status, "status '{$raw}' should equal PageStatusEnum::{$expected->name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue