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', '
Check this!
'), ]); 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); } public function test_poll_logs_a_structured_success_entry_with_url_count_and_duration(): void { Log::spy(); Event::fake([UrlDiscovered::class]); $instance = $this->makeInstance(); $this->pollInstance($instance, [ new FediversePost('1', 'https://mastodon.social/@alice/1', 'See https://example.com/one and https://other.example/two'), new FediversePost('2', 'https://mastodon.social/@bob/2', 'Also https://example.com/three'), ]); Log::shouldHaveReceived('info') ->once() ->withArgs(function (string $message, array $context) use ($instance): bool { return $message === 'fedi-discover:poll succeeded' && $context['instance_id'] === $instance->id && $context['url_count'] === 3 && isset($context['duration_ms']) && $context['duration_ms'] >= 0; }); } /** * @param array