144 - Extract SaveArticleAction from ArticleFetcher
This commit is contained in:
parent
c7b9cd6d0c
commit
d8b85f84ed
7 changed files with 134 additions and 116 deletions
52
app/Actions/SaveArticleAction.php
Normal file
52
app/Actions/SaveArticleAction.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
|
||||
class SaveArticleAction
|
||||
{
|
||||
public function __construct(
|
||||
private LogSaver $logSaver
|
||||
) {}
|
||||
|
||||
public function execute(string $url, ?int $feedId = null): Article
|
||||
{
|
||||
try {
|
||||
$article = Article::firstOrCreate(
|
||||
['url' => $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';
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
* @method static create(array<string, mixed> $array)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $feed_id
|
||||
* @property int|null $feed_id
|
||||
* @property Feed $feed
|
||||
* @property string $url
|
||||
* @property string $title
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
73
tests/Unit/Actions/SaveArticleActionTest.php
Normal file
73
tests/Unit/Actions/SaveArticleActionTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Actions;
|
||||
|
||||
use App\Actions\SaveArticleAction;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SaveArticleActionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function action(): SaveArticleAction
|
||||
{
|
||||
return app(SaveArticleAction::class);
|
||||
}
|
||||
|
||||
public function test_it_creates_an_article_that_does_not_exist(): void
|
||||
{
|
||||
$feed = Feed::factory()->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue