Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
7 changed files with 134 additions and 116 deletions
Showing only changes of commit d8b85f84ed - Show all commits

View 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';
}
}

View file

@ -17,7 +17,7 @@
* @method static create(array<string, mixed> $array) * @method static create(array<string, mixed> $array)
* *
* @property int $id * @property int $id
* @property int $feed_id * @property int|null $feed_id
* @property Feed $feed * @property Feed $feed
* @property string $url * @property string $url
* @property string $title * @property string $title

View file

@ -2,6 +2,7 @@
namespace App\Services\Article; namespace App\Services\Article;
use App\Actions\SaveArticleAction;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Factories\ArticleParserFactory; use App\Services\Factories\ArticleParserFactory;
@ -14,7 +15,8 @@
class ArticleFetcher class ArticleFetcher
{ {
public function __construct( 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) { foreach ($rss->channel->item as $item) {
$link = (string) $item->link; $link = (string) $item->link;
if ($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); $urls = $parser->extractArticleUrls($html);
return collect($urls) 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) { } catch (Exception $e) {
$this->logSaver->error('Failed to fetch articles from website feed', null, [ $this->logSaver->error('Failed to fetch articles from website feed', null, [
@ -136,46 +138,4 @@ public function fetchArticleData(Article $article): array
return []; 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';
}
} }

View file

@ -108,8 +108,3 @@ parameters:
count: 1 count: 1
path: tests/Unit/Models/RouteTest.php 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

View file

@ -2,6 +2,7 @@
namespace Tests\Traits; namespace Tests\Traits;
use App\Actions\SaveArticleAction;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Mockery; use Mockery;
@ -19,7 +20,7 @@ protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetc
$logSaver->shouldReceive('debug')->zeroOrMoreTimes(); $logSaver->shouldReceive('debug')->zeroOrMoreTimes();
} }
return new ArticleFetcher($logSaver); return new ArticleFetcher($logSaver, new SaveArticleAction($logSaver));
} }
/** @return array{ArticleFetcher, MockInterface} */ /** @return array{ArticleFetcher, MockInterface} */
@ -31,7 +32,7 @@ protected function createArticleFetcherWithMockedLogSaver(): array
$logSaver->shouldReceive('error')->zeroOrMoreTimes(); $logSaver->shouldReceive('error')->zeroOrMoreTimes();
$logSaver->shouldReceive('debug')->zeroOrMoreTimes(); $logSaver->shouldReceive('debug')->zeroOrMoreTimes();
$articleFetcher = new ArticleFetcher($logSaver); $articleFetcher = new ArticleFetcher($logSaver, new SaveArticleAction($logSaver));
return [$articleFetcher, $logSaver]; return [$articleFetcher, $logSaver];
} }

View 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);
}
}

View file

@ -203,69 +203,6 @@ public function test_fetch_article_data_handles_unsupported_domain(): void
$this->assertEmpty($result); $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 protected function tearDown(): void
{ {
Mockery::close(); Mockery::close();