diff --git a/app/Actions/SaveArticleAction.php b/app/Actions/SaveArticleAction.php new file mode 100644 index 00000000..7759efeb --- /dev/null +++ b/app/Actions/SaveArticleAction.php @@ -0,0 +1,52 @@ + $url], + [ + 'feed_id' => $feedId, + 'title' => $this->generateFallbackTitle($url), + ] + ); + + if ($article->wasRecentlyCreated) { + $article->dispatchFetchedEvent(); + } + + return $article; + } catch (Exception $e) { + $this->logSaver->error('Failed to create article', null, [ + 'url' => $url, + 'feed_id' => $feedId, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + private function generateFallbackTitle(string $url): string + { + $path = parse_url($url, PHP_URL_PATH); + $filename = basename($path ?: $url); + + $title = preg_replace('/\.[^.]*$/', '', $filename); + $title = str_replace(['-', '_'], ' ', $title); + $title = ucwords($title); + + return $title ?: 'Untitled Article'; + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index dfd03eaa..46d07d53 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -17,7 +17,7 @@ * @method static create(array $array) * * @property int $id - * @property int $feed_id + * @property int|null $feed_id * @property Feed $feed * @property string $url * @property string $title diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index e655e8d5..731eb4e5 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -2,6 +2,7 @@ namespace App\Services\Article; +use App\Actions\SaveArticleAction; use App\Models\Article; use App\Models\Feed; use App\Services\Factories\ArticleParserFactory; @@ -14,7 +15,8 @@ class ArticleFetcher { public function __construct( - private LogSaver $logSaver + private LogSaver $logSaver, + private SaveArticleAction $saveArticle, ) {} /** @@ -66,7 +68,7 @@ private function getArticlesFromRssFeed(Feed $feed): Collection foreach ($rss->channel->item as $item) { $link = (string) $item->link; if ($link !== '') { - $articles->push($this->saveArticle($link, $feed->id)); + $articles->push($this->saveArticle->execute($link, $feed->id)); } } @@ -104,7 +106,7 @@ private function getArticlesFromWebsiteFeed(Feed $feed): Collection $urls = $parser->extractArticleUrls($html); return collect($urls) - ->map(fn (string $url) => $this->saveArticle($url, $feed->id)); + ->map(fn (string $url) => $this->saveArticle->execute($url, $feed->id)); } catch (Exception $e) { $this->logSaver->error('Failed to fetch articles from website feed', null, [ @@ -136,46 +138,4 @@ public function fetchArticleData(Article $article): array return []; } } - - private function saveArticle(string $url, ?int $feedId = null): Article - { - $fallbackTitle = $this->generateFallbackTitle($url); - - try { - $article = Article::firstOrCreate( - ['url' => $url], - [ - 'feed_id' => $feedId, - 'title' => $fallbackTitle, - ] - ); - - if ($article->wasRecentlyCreated) { - $article->dispatchFetchedEvent(); - } - - return $article; - } catch (Exception $e) { - $this->logSaver->error('Failed to create article', null, [ - 'url' => $url, - 'feed_id' => $feedId, - 'error' => $e->getMessage(), - ]); - throw $e; - } - } - - private function generateFallbackTitle(string $url): string - { - // Extract filename from URL as a basic fallback title - $path = parse_url($url, PHP_URL_PATH); - $filename = basename($path ?: $url); - - // Remove file extension and convert to readable format - $title = preg_replace('/\.[^.]*$/', '', $filename); - $title = str_replace(['-', '_'], ' ', $title); - $title = ucwords($title); - - return $title ?: 'Untitled Article'; - } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 93a19e71..bc6e16d0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -108,8 +108,3 @@ parameters: count: 1 path: tests/Unit/Models/RouteTest.php - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#' - identifier: method.impossibleType - count: 1 - path: tests/Unit/Services/ArticleFetcherTest.php diff --git a/tests/Traits/CreatesArticleFetcher.php b/tests/Traits/CreatesArticleFetcher.php index adeda40b..af426043 100644 --- a/tests/Traits/CreatesArticleFetcher.php +++ b/tests/Traits/CreatesArticleFetcher.php @@ -2,6 +2,7 @@ namespace Tests\Traits; +use App\Actions\SaveArticleAction; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; use Mockery; @@ -19,7 +20,7 @@ protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetc $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); } - return new ArticleFetcher($logSaver); + return new ArticleFetcher($logSaver, new SaveArticleAction($logSaver)); } /** @return array{ArticleFetcher, MockInterface} */ @@ -31,7 +32,7 @@ protected function createArticleFetcherWithMockedLogSaver(): array $logSaver->shouldReceive('error')->zeroOrMoreTimes(); $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $articleFetcher = new ArticleFetcher($logSaver); + $articleFetcher = new ArticleFetcher($logSaver, new SaveArticleAction($logSaver)); return [$articleFetcher, $logSaver]; } diff --git a/tests/Unit/Actions/SaveArticleActionTest.php b/tests/Unit/Actions/SaveArticleActionTest.php new file mode 100644 index 00000000..cdb5818b --- /dev/null +++ b/tests/Unit/Actions/SaveArticleActionTest.php @@ -0,0 +1,73 @@ +create(); + $url = 'https://example.com/unique-article'; + + $this->assertDatabaseMissing('articles', ['url' => $url]); + + $article = $this->action()->execute($url, $feed->id); + + $this->assertInstanceOf(Article::class, $article); + $this->assertSame($url, $article->url); + $this->assertSame($feed->id, $article->feed_id); + $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => $feed->id]); + } + + public function test_it_returns_the_existing_article_instead_of_duplicating(): void + { + $feed = Feed::factory()->create(); + $existing = Article::factory()->create([ + 'url' => 'https://example.com/existing-article', + 'feed_id' => $feed->id, + ]); + + $article = $this->action()->execute($existing->url, $feed->id); + + $this->assertSame($existing->id, $article->id); + $this->assertSame(1, Article::where('url', $existing->url)->count()); + } + + public function test_it_creates_an_article_without_a_feed(): void + { + $url = 'https://example.com/article-without-feed'; + + $article = $this->action()->execute($url, null); + + $this->assertSame($url, $article->url); + $this->assertNull($article->feed_id); + $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => null]); + } + + public function test_it_derives_a_readable_fallback_title_from_the_url(): void + { + $article = $this->action()->execute('https://example.com/some-great_story.html'); + + $this->assertSame('Some Great Story', $article->title); + } + + public function test_it_falls_back_to_untitled_when_the_url_has_no_usable_path(): void + { + $article = $this->action()->execute('https://example.com/'); + + $this->assertSame('Untitled Article', $article->title); + } +} diff --git a/tests/Unit/Services/ArticleFetcherTest.php b/tests/Unit/Services/ArticleFetcherTest.php index f281bea4..7ca7df96 100644 --- a/tests/Unit/Services/ArticleFetcherTest.php +++ b/tests/Unit/Services/ArticleFetcherTest.php @@ -203,69 +203,6 @@ public function test_fetch_article_data_handles_unsupported_domain(): void $this->assertEmpty($result); } - public function test_save_article_creates_new_article_when_not_exists(): void - { - $feed = Feed::factory()->create(); - $url = 'https://example.com/unique-article'; - - // Ensure article doesn't exist - $this->assertDatabaseMissing('articles', ['url' => $url]); - - $articleFetcher = $this->createArticleFetcher(); - // Use reflection to access private method for testing - $reflection = new \ReflectionClass($articleFetcher); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $article = $saveArticleMethod->invoke($articleFetcher, $url, $feed->id); - - $this->assertInstanceOf(Article::class, $article); - $this->assertEquals($url, $article->url); - $this->assertEquals($feed->id, $article->feed_id); - $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => $feed->id]); - } - - public function test_save_article_returns_existing_article_when_exists(): void - { - $feed = Feed::factory()->create(); - $existingArticle = Article::factory()->create([ - 'url' => 'https://example.com/existing-article', - 'feed_id' => $feed->id, - ]); - - // Use reflection to access private method for testing - $reflection = new \ReflectionClass(ArticleFetcher::class); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $articleFetcher = $this->createArticleFetcher(); - $article = $saveArticleMethod->invoke($articleFetcher, $existingArticle->url, $feed->id); - - $this->assertEquals($existingArticle->id, $article->id); - $this->assertEquals($existingArticle->url, $article->url); - - // Ensure no duplicate was created - $this->assertEquals(1, Article::where('url', $existingArticle->url)->count()); - } - - public function test_save_article_without_feed_id(): void - { - $url = 'https://example.com/article-without-feed'; - - // Use reflection to access private method for testing - $reflection = new \ReflectionClass(ArticleFetcher::class); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $articleFetcher = $this->createArticleFetcher(); - $article = $saveArticleMethod->invoke($articleFetcher, $url, null); - - $this->assertInstanceOf(Article::class, $article); - $this->assertEquals($url, $article->url); - $this->assertNull($article->feed_id); - $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => null]); - } - protected function tearDown(): void { Mockery::close();