Compare commits

...

10 commits

31 changed files with 1621 additions and 43 deletions

View 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';
}

View 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
View 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
View 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');
}
}

View file

@ -2,7 +2,10 @@
namespace App\Providers;
use App\Listeners\UrlDiscoveredListener;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lvl0\FediDiscover\Events\UrlDiscovered;
class AppServiceProvider extends ServiceProvider
{
@ -19,6 +22,6 @@ public function register(): void
*/
public function boot(): void
{
//
Event::listen(UrlDiscovered::class, UrlDiscoveredListener::class);
}
}

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\PageStatusEnum;
use App\Models\Page;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -11,14 +14,13 @@
class PageFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
//
'url' => fake()->url(),
'status' => PageStatusEnum::Discovered,
];
}
}

View 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,
]);
}
}

View 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');
}
};

View file

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

View file

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

View file

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

View file

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

View 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']
);
});
}
}

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

View file

@ -7,4 +7,5 @@
enum InstanceType: string
{
case Mastodon = 'mastodon';
case Lemmy = 'lemmy';
}

View file

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

View file

@ -1,26 +1,22 @@
<?php
declare(strict_types=1);
namespace Lvl0\FediDiscover\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UrlDiscovered
{
use Dispatchable, InteractsWithSockets, SerializesModels;
use Dispatchable, SerializesModels;
public function __construct(
public string $url,
public ?string $postUrl,
public ?string $postBody
public int $instanceId,
public CarbonImmutable $discoveredAt,
public ?string $postUrl = null,
public ?string $postBody = null,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('discovery'),
];
}
}

View file

@ -4,32 +4,31 @@
namespace Lvl0\FediDiscover;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lvl0\FediDiscover\Clients\FediverseClientFactory;
use Lvl0\FediDiscover\Console\Commands\PollInstancesCommand;
use Lvl0\FediDiscover\Console\Commands\ValidateInstancesCommand;
use Lvl0\FediDiscover\Events\UrlDiscovered;
class FediDiscoverServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/fedi-discover.php', 'fedi-discover');
$this->app->singleton(FediverseClientFactory::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
Event::listen(
UrlDiscovered::class,
);
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/fedi-discover.php' => config_path('fedi-discover.php'),
], 'fedi-discover-config');
$this->commands([
PollInstancesCommand::class,
ValidateInstancesCommand::class,
]);
}

View file

@ -19,7 +19,7 @@
* @property bool $enabled
* @property int $interval_seconds
* @property array<string, mixed> $extras
* @property int $last_seen_id
* @property string|null $last_seen_id
* @property Carbon|null $last_polled_at
* @property Carbon $created_at
* @property Carbon $updated_at
@ -40,7 +40,11 @@ class Instance extends Model
'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);
}

View file

@ -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,
) {}
}

View file

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

View 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,
],
];
}
}

View 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'],
];
}
}

View file

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

View file

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

View file

@ -4,6 +4,7 @@
namespace Lvl0\FediDiscover\Tests\Unit;
use Carbon\CarbonImmutable;
use Lvl0\FediDiscover\Events\UrlDiscovered;
use PHPUnit\Framework\TestCase;
@ -11,13 +12,19 @@ class UrlDiscoveredTest extends TestCase
{
public function test_it_exposes_all_payload_fields(): void
{
$discoveredAt = CarbonImmutable::parse('2026-04-26T12:00:00');
$event = new UrlDiscovered(
url: 'https://example.com/article',
instanceId: 42,
discoveredAt: $discoveredAt,
postUrl: 'https://mastodon.social/@alice/109876543210',
postBody: 'Check out this article: https://example.com/article'
);
$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('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(
url: 'https://example.com/article',
instanceId: 1,
discoveredAt: CarbonImmutable::parse('2026-04-26T12:00:00'),
postUrl: 'https://mastodon.social/@alice/109876543210',
postBody: null
);

View file

@ -3,6 +3,11 @@
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
displayDetailsOnPhpunitDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerNotices="true"
>
<testsuites>
<testsuite name="Unit">
@ -22,19 +27,21 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<server name="APP_ENV" value="testing"/>
<server name="APP_MAINTENANCE_DRIVER" value="file"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="BROADCAST_CONNECTION" value="null"/>
<server name="CACHE_STORE" value="array"/>
<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>
<server name="DB_URL" value=""/>
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="PULSE_ENABLED" value="false"/>
<server name="TELESCOPE_ENABLED" value="false"/>
<server name="NIGHTWATCH_ENABLED" value="false"/>
<ini name="display_errors" value="On"/>
<ini name="error_reporting" value="-1"/>
</php>
</phpunit>

View file

@ -1,8 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('fedi-discover:poll')
->everyMinute()
->withoutOverlapping(5)
->runInBackground();

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

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

View 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}");
}
}
}