4 - Add UrlDiscoveredListener wiring fediverse polling to pages graph
This commit is contained in:
parent
424ad2ff78
commit
3ad473f4a1
5 changed files with 203 additions and 17 deletions
43
app/Listeners/UrlDiscoveredListener.php
Normal file
43
app/Listeners/UrlDiscoveredListener.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?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) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourcePage = Page::firstOrCreate(
|
||||
['url' => $event->postUrl],
|
||||
[
|
||||
'status' => PageStatusEnum::Fetched,
|
||||
'instance_id' => $event->instanceId,
|
||||
'fetched_at' => $event->discoveredAt,
|
||||
],
|
||||
);
|
||||
|
||||
PageLink::firstOrCreate([
|
||||
'source_page_id' => $sourcePage->id,
|
||||
'target_page_id' => $targetPage->id,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ public function up(): void
|
|||
{
|
||||
Schema::create('page_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('source_page_id')->constrained('pages')->cascadeOnDelete();
|
||||
$table->foreignId('target_page_id')->constrained('pages')->cascadeOnDelete();
|
||||
$table->foreignId('source_page_id')->constrained('pages');
|
||||
$table->foreignId('target_page_id')->constrained('pages');
|
||||
$table->timestampsTz();
|
||||
|
||||
$table->unique(['source_page_id', 'target_page_id']);
|
||||
|
|
|
|||
28
phpunit.xml
28
phpunit.xml
|
|
@ -27,20 +27,20 @@
|
|||
</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>
|
||||
|
|
|
|||
140
tests/Feature/UrlDiscoveryTest.php
Normal file
140
tests/Feature/UrlDiscoveryTest.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?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::Fetched, $sourcePage->status);
|
||||
$this->assertSame($instance->id, $sourcePage->instance_id);
|
||||
$this->assertNotNull($sourcePage->fetched_at);
|
||||
$this->assertTrue($discoveredAt->equalTo($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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue