diff --git a/app/Actions/FetchRssArticlesAction.php b/app/Actions/FetchRssArticlesAction.php new file mode 100644 index 00000000..de4525a8 --- /dev/null +++ b/app/Actions/FetchRssArticlesAction.php @@ -0,0 +1,64 @@ + + */ + public function execute(Feed $feed): Collection + { + try { + $xml = HttpFetcher::fetchHtml($feed->url); + + $previousUseErrors = libxml_use_internal_errors(true); + + try { + $rss = simplexml_load_string($xml); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previousUseErrors); + } + + if ($rss === false || ! isset($rss->channel->item)) { + $this->logSaver->warning('Failed to parse RSS feed XML', null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + ]); + + return collect(); + } + + $articles = collect(); + foreach ($rss->channel->item as $item) { + $link = (string) $item->link; + if ($link !== '') { + $articles->push($this->saveArticle->execute($link, $feed->id)); + } + } + + return $articles; + } catch (Exception $e) { + $this->logSaver->error('Failed to fetch articles from RSS feed', null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + 'error' => $e->getMessage(), + ]); + + return collect(); + } + } +} diff --git a/app/Actions/FetchWebsiteArticlesAction.php b/app/Actions/FetchWebsiteArticlesAction.php new file mode 100644 index 00000000..edc0aa6a --- /dev/null +++ b/app/Actions/FetchWebsiteArticlesAction.php @@ -0,0 +1,53 @@ + + */ + public function execute(Feed $feed): Collection + { + try { + $parser = HomepageParserFactory::getParserForFeed($feed); + + if (! $parser) { + $this->logSaver->warning('No parser available for feed URL', null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + ]); + + return collect(); + } + + $html = HttpFetcher::fetchHtml($feed->url); + $urls = $parser->extractArticleUrls($html); + + return collect($urls) + ->map(fn (string $url) => $this->saveArticle->execute($url, $feed->id)); + + } catch (Exception $e) { + $this->logSaver->error('Failed to fetch articles from website feed', null, [ + 'feed_id' => $feed->id, + 'feed_url' => $feed->url, + 'error' => $e->getMessage(), + ]); + + return collect(); + } + } +} diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index 731eb4e5..e801993d 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -2,11 +2,11 @@ namespace App\Services\Article; -use App\Actions\SaveArticleAction; +use App\Actions\FetchRssArticlesAction; +use App\Actions\FetchWebsiteArticlesAction; use App\Models\Article; use App\Models\Feed; use App\Services\Factories\ArticleParserFactory; -use App\Services\Factories\HomepageParserFactory; use App\Services\Http\HttpFetcher; use App\Services\Log\LogSaver; use Exception; @@ -16,7 +16,8 @@ class ArticleFetcher { public function __construct( private LogSaver $logSaver, - private SaveArticleAction $saveArticle, + private FetchRssArticlesAction $fetchRssArticles, + private FetchWebsiteArticlesAction $fetchWebsiteArticles, ) {} /** @@ -25,9 +26,9 @@ public function __construct( public function getArticlesFromFeed(Feed $feed): Collection { if ($feed->type === 'rss') { - return $this->getArticlesFromRssFeed($feed); + return $this->fetchRssArticles->execute($feed); } elseif ($feed->type === 'website') { - return $this->getArticlesFromWebsiteFeed($feed); + return $this->fetchWebsiteArticles->execute($feed); } $this->logSaver->warning('Unsupported feed type', null, [ @@ -38,87 +39,6 @@ public function getArticlesFromFeed(Feed $feed): Collection return collect(); } - /** - * @return Collection - */ - private function getArticlesFromRssFeed(Feed $feed): Collection - { - try { - $xml = HttpFetcher::fetchHtml($feed->url); - - $previousUseErrors = libxml_use_internal_errors(true); - - try { - $rss = simplexml_load_string($xml); - } finally { - libxml_clear_errors(); - libxml_use_internal_errors($previousUseErrors); - } - - if ($rss === false || ! isset($rss->channel->item)) { - $this->logSaver->warning('Failed to parse RSS feed XML', null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url, - ]); - - return collect(); - } - - $articles = collect(); - foreach ($rss->channel->item as $item) { - $link = (string) $item->link; - if ($link !== '') { - $articles->push($this->saveArticle->execute($link, $feed->id)); - } - } - - return $articles; - } catch (Exception $e) { - $this->logSaver->error('Failed to fetch articles from RSS feed', null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url, - 'error' => $e->getMessage(), - ]); - - return collect(); - } - } - - /** - * @return Collection - */ - private function getArticlesFromWebsiteFeed(Feed $feed): Collection - { - try { - // Try to get parser for this feed - $parser = HomepageParserFactory::getParserForFeed($feed); - - if (! $parser) { - $this->logSaver->warning('No parser available for feed URL', null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url, - ]); - - return collect(); - } - - $html = HttpFetcher::fetchHtml($feed->url); - $urls = $parser->extractArticleUrls($html); - - return collect($urls) - ->map(fn (string $url) => $this->saveArticle->execute($url, $feed->id)); - - } catch (Exception $e) { - $this->logSaver->error('Failed to fetch articles from website feed', null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url, - 'error' => $e->getMessage(), - ]); - - return collect(); - } - } - /** * @return array */ diff --git a/tests/Traits/CreatesArticleFetcher.php b/tests/Traits/CreatesArticleFetcher.php index af426043..4783b680 100644 --- a/tests/Traits/CreatesArticleFetcher.php +++ b/tests/Traits/CreatesArticleFetcher.php @@ -2,6 +2,8 @@ namespace Tests\Traits; +use App\Actions\FetchRssArticlesAction; +use App\Actions\FetchWebsiteArticlesAction; use App\Actions\SaveArticleAction; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; @@ -13,18 +15,33 @@ trait CreatesArticleFetcher protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetcher { if (! $logSaver) { - $logSaver = Mockery::mock(LogSaver::class); - $logSaver->shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); + $logSaver = $this->mockLogSaver(); } - return new ArticleFetcher($logSaver, new SaveArticleAction($logSaver)); + return $this->articleFetcherWith($logSaver); } /** @return array{ArticleFetcher, MockInterface} */ protected function createArticleFetcherWithMockedLogSaver(): array + { + $logSaver = $this->mockLogSaver(); + + return [$this->articleFetcherWith($logSaver), $logSaver]; + } + + private function articleFetcherWith(LogSaver $logSaver): ArticleFetcher + { + $saveArticle = new SaveArticleAction($logSaver); + + return new ArticleFetcher( + $logSaver, + new FetchRssArticlesAction($logSaver, $saveArticle), + new FetchWebsiteArticlesAction($logSaver, $saveArticle), + ); + } + + /** @return LogSaver&MockInterface */ + private function mockLogSaver(): MockInterface { $logSaver = Mockery::mock(LogSaver::class); $logSaver->shouldReceive('info')->zeroOrMoreTimes(); @@ -32,8 +49,6 @@ protected function createArticleFetcherWithMockedLogSaver(): array $logSaver->shouldReceive('error')->zeroOrMoreTimes(); $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $articleFetcher = new ArticleFetcher($logSaver, new SaveArticleAction($logSaver)); - - return [$articleFetcher, $logSaver]; + return $logSaver; } } diff --git a/tests/Unit/Actions/FetchRssArticlesActionTest.php b/tests/Unit/Actions/FetchRssArticlesActionTest.php new file mode 100644 index 00000000..3d981a3f --- /dev/null +++ b/tests/Unit/Actions/FetchRssArticlesActionTest.php @@ -0,0 +1,112 @@ +{$link}"; + } + + return "{$items}"; + } + + private function feed(): Feed + { + return Feed::factory()->create([ + 'type' => 'rss', + 'url' => 'https://example.com/feed.rss', + ]); + } + + public function test_it_saves_an_article_per_rss_item(): void + { + $feed = $this->feed(); + + Http::fake([$feed->url => Http::response( + $this->rss('https://example.com/one', 'https://example.com/two'), 200 + )]); + + $result = $this->action()->execute($feed); + + $this->assertCount(2, $result); + $this->assertDatabaseHas('articles', ['url' => 'https://example.com/one', 'feed_id' => $feed->id]); + $this->assertDatabaseHas('articles', ['url' => 'https://example.com/two', 'feed_id' => $feed->id]); + } + + public function test_it_skips_items_with_an_empty_link(): void + { + $feed = $this->feed(); + + Http::fake([$feed->url => Http::response( + $this->rss('https://example.com/one', ''), 200 + )]); + + $result = $this->action()->execute($feed); + + $this->assertCount(1, $result); + $this->assertSame(1, Article::count()); + } + + public function test_it_returns_empty_for_malformed_xml(): void + { + $feed = $this->feed(); + + Http::fake([$feed->url => Http::response('assertEmpty($this->action()->execute($feed)); + $this->assertSame(0, Article::count()); + } + + public function test_it_returns_empty_when_the_feed_has_no_items(): void + { + $feed = $this->feed(); + + Http::fake([$feed->url => Http::response('', 200)]); + + $this->assertEmpty($this->action()->execute($feed)); + } + + public function test_it_returns_empty_when_the_fetch_throws(): void + { + $feed = $this->feed(); + + Http::fake(fn () => throw new \RuntimeException('connection refused')); + + $this->assertEmpty($this->action()->execute($feed)); + } + + public function test_it_does_not_duplicate_articles_across_runs(): void + { + $feed = $this->feed(); + + Http::fake([$feed->url => Http::response($this->rss('https://example.com/one'), 200)]); + + $this->action()->execute($feed); + $this->action()->execute($feed); + + $this->assertSame(1, Article::count()); + } +} diff --git a/tests/Unit/Actions/FetchWebsiteArticlesActionTest.php b/tests/Unit/Actions/FetchWebsiteArticlesActionTest.php new file mode 100644 index 00000000..4153a587 --- /dev/null +++ b/tests/Unit/Actions/FetchWebsiteArticlesActionTest.php @@ -0,0 +1,64 @@ + Http::response('', 200)]); + + $feed = Feed::factory()->create([ + 'type' => 'website', + 'url' => 'https://no-parser-for-this-domain.example/', + ]); + + $this->assertEmpty($this->action()->execute($feed)); + $this->assertSame(0, Article::count()); + } + + public function test_it_returns_empty_when_the_fetch_throws(): void + { + Http::fake(fn () => throw new \RuntimeException('connection refused')); + + $feed = Feed::factory()->create([ + 'type' => 'website', + 'url' => 'https://www.vrt.be/vrtnws/nl/', + ]); + + $this->assertEmpty($this->action()->execute($feed)); + } + + public function test_it_returns_a_collection_for_a_feed_with_a_parser(): void + { + Http::fake([ + 'https://www.vrt.be/vrtnws/nl/' => Http::response('Sample VRT content', 200), + ]); + + $feed = Feed::factory()->create([ + 'type' => 'website', + 'url' => 'https://www.vrt.be/vrtnws/nl/', + ]); + + $this->assertInstanceOf(Collection::class, $this->action()->execute($feed)); + } +}