From 3ad473f4a107a266c986c68af1f607bd17287f1e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 26 Apr 2026 03:31:32 +0200 Subject: [PATCH] 4 - Add UrlDiscoveredListener wiring fediverse polling to pages graph --- app/Listeners/UrlDiscoveredListener.php | 43 ++++++ app/Providers/AppServiceProvider.php | 5 +- ...6_04_26_001957_create_page_links_table.php | 4 +- phpunit.xml | 28 ++-- tests/Feature/UrlDiscoveryTest.php | 140 ++++++++++++++++++ 5 files changed, 203 insertions(+), 17 deletions(-) create mode 100644 app/Listeners/UrlDiscoveredListener.php create mode 100644 tests/Feature/UrlDiscoveryTest.php diff --git a/app/Listeners/UrlDiscoveredListener.php b/app/Listeners/UrlDiscoveredListener.php new file mode 100644 index 0000000..f528017 --- /dev/null +++ b/app/Listeners/UrlDiscoveredListener.php @@ -0,0 +1,43 @@ + $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, + ]); + }); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..5cafe3e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } } diff --git a/database/migrations/2026_04_26_001957_create_page_links_table.php b/database/migrations/2026_04_26_001957_create_page_links_table.php index 296b994..b67328c 100644 --- a/database/migrations/2026_04_26_001957_create_page_links_table.php +++ b/database/migrations/2026_04_26_001957_create_page_links_table.php @@ -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']); diff --git a/phpunit.xml b/phpunit.xml index 9ca208c..ac75c66 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -27,20 +27,20 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/tests/Feature/UrlDiscoveryTest.php b/tests/Feature/UrlDiscoveryTest.php new file mode 100644 index 0000000..014567a --- /dev/null +++ b/tests/Feature/UrlDiscoveryTest.php @@ -0,0 +1,140 @@ +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; + }); + } +}