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
21 changed files with 260 additions and 254 deletions
Showing only changes of commit dc64dd83b8 - Show all commits

View file

@ -0,0 +1,36 @@
<?php
namespace App\Actions;
use App\Models\Article;
use App\Services\Factories\ArticleParserFactory;
use App\Services\Http\HttpFetcher;
use App\Services\Log\LogSaver;
use Exception;
class FetchArticleDataAction
{
public function __construct(
private LogSaver $logSaver
) {}
/**
* @return array<string, mixed>
*/
public function execute(Article $article): array
{
try {
$html = HttpFetcher::fetchHtml($article->url);
$parser = ArticleParserFactory::getParser($article->url);
return $parser->extractData($html);
} catch (Exception $e) {
$this->logSaver->error('Exception while fetching article data', null, [
'url' => $article->url,
'error' => $e->getMessage(),
]);
return [];
}
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Actions;
use App\Models\Article;
use App\Models\Feed;
use App\Services\Log\LogSaver;
use Illuminate\Support\Collection;
class FetchFeedArticlesAction
{
public function __construct(
private LogSaver $logSaver,
private FetchRssArticlesAction $fetchRssArticles,
private FetchWebsiteArticlesAction $fetchWebsiteArticles,
) {}
/**
* @return Collection<int, Article>
*/
public function execute(Feed $feed): Collection
{
if ($feed->type === 'rss') {
return $this->fetchRssArticles->execute($feed);
} elseif ($feed->type === 'website') {
return $this->fetchWebsiteArticles->execute($feed);
}
$this->logSaver->warning('Unsupported feed type', null, [
'feed_id' => $feed->id,
'feed_type' => $feed->type,
]);
return collect();
}
}

View file

@ -12,7 +12,6 @@
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\Article; use App\Models\Article;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome; use App\Services\Publishing\PublishOutcome;
@ -21,7 +20,7 @@
class PublishRouteArticleAction class PublishRouteArticleAction
{ {
public function __construct( public function __construct(
private ArticleFetcher $articleFetcher, private FetchArticleDataAction $fetchArticleData,
private ArticlePublishingService $publishingService, private ArticlePublishingService $publishingService,
private NotificationService $notificationService, private NotificationService $notificationService,
) {} ) {}
@ -90,7 +89,7 @@ private function hasPublishableContent(array $extractedData): bool
private function resolvePublishData(Article $article): array private function resolvePublishData(Article $article): array
{ {
if (empty($article->description) && empty($article->image_url)) { if (empty($article->description) && empty($article->image_url)) {
return $this->articleFetcher->fetchArticleData($article); return $this->fetchArticleData->execute($article);
} }
return [ return [

View file

@ -3,12 +3,11 @@
namespace App\Actions; namespace App\Actions;
use App\Models\Article; use App\Models\Article;
use App\Services\Article\ArticleFetcher;
class ValidateArticleAction class ValidateArticleAction
{ {
public function __construct( public function __construct(
private ArticleFetcher $articleFetcher, private FetchArticleDataAction $fetchArticleData,
private CreateRouteArticlesAction $createRouteArticles, private CreateRouteArticlesAction $createRouteArticles,
) {} ) {}
@ -16,7 +15,7 @@ public function execute(Article $article): Article
{ {
logger('Validating article for routes: '.$article->id); logger('Validating article for routes: '.$article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->fetchArticleData->execute($article);
$updateData = []; $updateData = [];

View file

@ -2,13 +2,13 @@
namespace App\Jobs; namespace App\Jobs;
use App\Actions\FetchFeedArticlesAction;
use App\Enums\ActivityTypeEnum; use App\Enums\ActivityTypeEnum;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Events\ActivityLogged; use App\Events\ActivityLogged;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Notification; use App\Models\Notification;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -26,7 +26,7 @@ public function __construct(
$this->onQueue('feed-discovery'); $this->onQueue('feed-discovery');
} }
public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher, NotificationService $notificationService): void public function handle(LogSaver $logSaver, FetchFeedArticlesAction $fetchFeedArticles, NotificationService $notificationService): void
{ {
$logSaver->info('Starting feed article fetch', null, [ $logSaver->info('Starting feed article fetch', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,
@ -34,7 +34,7 @@ public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher, Notif
'feed_url' => $this->feed->url, 'feed_url' => $this->feed->url,
]); ]);
$articles = $articleFetcher->getArticlesFromFeed($this->feed); $articles = $fetchFeedArticles->execute($this->feed);
$logSaver->info('Feed article fetch completed', null, [ $logSaver->info('Feed article fetch completed', null, [
'feed_id' => $this->feed->id, 'feed_id' => $this->feed->id,

View file

@ -1,61 +0,0 @@
<?php
namespace App\Services\Article;
use App\Actions\FetchRssArticlesAction;
use App\Actions\FetchWebsiteArticlesAction;
use App\Models\Article;
use App\Models\Feed;
use App\Services\Factories\ArticleParserFactory;
use App\Services\Http\HttpFetcher;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Support\Collection;
class ArticleFetcher
{
public function __construct(
private LogSaver $logSaver,
private FetchRssArticlesAction $fetchRssArticles,
private FetchWebsiteArticlesAction $fetchWebsiteArticles,
) {}
/**
* @return Collection<int, Article>
*/
public function getArticlesFromFeed(Feed $feed): Collection
{
if ($feed->type === 'rss') {
return $this->fetchRssArticles->execute($feed);
} elseif ($feed->type === 'website') {
return $this->fetchWebsiteArticles->execute($feed);
}
$this->logSaver->warning('Unsupported feed type', null, [
'feed_id' => $feed->id,
'feed_type' => $feed->type,
]);
return collect();
}
/**
* @return array<string, mixed>
*/
public function fetchArticleData(Article $article): array
{
try {
$html = HttpFetcher::fetchHtml($article->url);
$parser = ArticleParserFactory::getParser($article->url);
return $parser->extractData($html);
} catch (Exception $e) {
$this->logSaver->error('Exception while fetching article data', null, [
'url' => $article->url,
'error' => $e->getMessage(),
]);
return [];
}
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction; use App\Actions\PublishRouteArticleAction;
use App\Enums\PublishStatusEnum; use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved; use App\Events\RouteArticleApproved;
@ -15,7 +16,6 @@
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
@ -93,8 +93,8 @@ private function makeListener(): PublishApprovedArticleListener
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisher); $service->shouldReceive('makePublisher')->andReturn($publisher);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService)); return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
} }
@ -159,8 +159,8 @@ public function test_two_queued_listeners_create_only_one_remote_post(): void
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisher); $service->shouldReceive('makePublisher')->andReturn($publisher);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService)); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));

View file

@ -2,6 +2,7 @@
namespace Tests\Feature\Jobs; namespace Tests\Feature\Jobs;
use App\Actions\FetchFeedArticlesAction;
use App\Enums\ActivityTypeEnum; use App\Enums\ActivityTypeEnum;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
@ -10,7 +11,6 @@
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Notification; use App\Models\Notification;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -27,8 +27,8 @@ class ArticleDiscoveryForFeedJobEmptyFetchTest extends TestCase
*/ */
private function runJobForFeed(Feed $feed, ?Collection $articles = null): void private function runJobForFeed(Feed $feed, ?Collection $articles = null): void
{ {
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchFeedArticlesAction::class);
$fetcher->shouldReceive('getArticlesFromFeed') $fetcher->shouldReceive('execute')
->andReturn($articles ?? collect()); ->andReturn($articles ?? collect());
(new ArticleDiscoveryForFeedJob($feed))->handle( (new ArticleDiscoveryForFeedJob($feed))->handle(

View file

@ -2,6 +2,8 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\FetchArticleDataAction;
use App\Actions\FetchFeedArticlesAction;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed; use App\Events\ActionPerformed;
@ -23,7 +25,6 @@
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -63,18 +64,18 @@ public function test_article_discovery_for_feed_job_processes_feed(): void
'is_active' => true, 'is_active' => true,
]); ]);
// Mock the ArticleFetcher service in the container // Mock the feed fetch in the container
$mockFetcher = \Mockery::mock(ArticleFetcher::class); $mockFetcher = \Mockery::mock(FetchFeedArticlesAction::class);
$article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]); $article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]);
$article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]); $article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]);
$mockFetcher->shouldReceive('getArticlesFromFeed') $mockFetcher->shouldReceive('execute')
->with($feed) ->with($feed)
->andReturn(collect([$article1, $article2])); ->andReturn(collect([$article1, $article2]));
$this->app->instance(ArticleFetcher::class, $mockFetcher); $this->app->instance(FetchFeedArticlesAction::class, $mockFetcher);
$logSaver = app(LogSaver::class); $logSaver = app(LogSaver::class);
$articleFetcher = app(ArticleFetcher::class); $articleFetcher = app(FetchFeedArticlesAction::class);
$job = new ArticleDiscoveryForFeedJob($feed); $job = new ArticleDiscoveryForFeedJob($feed);
$job->handle($logSaver, $articleFetcher, app(NotificationService::class)); $job->handle($logSaver, $articleFetcher, app(NotificationService::class));
@ -167,10 +168,10 @@ public function test_validate_article_listener_processes_new_article(): void
'feed_id' => $feed->id, 'feed_id' => $feed->id,
]); ]);
// Mock ArticleFetcher to return valid article data // Mock the article-data fetch to return valid data
$mockFetcher = \Mockery::mock(ArticleFetcher::class); $mockFetcher = \Mockery::mock(FetchArticleDataAction::class);
$this->app->instance(ArticleFetcher::class, $mockFetcher); $this->app->instance(FetchArticleDataAction::class, $mockFetcher);
$mockFetcher->shouldReceive('fetchArticleData') $mockFetcher->shouldReceive('execute')
->with($article) ->with($article)
->andReturn([ ->andReturn([
'title' => 'Belgian News', 'title' => 'Belgian News',

View file

@ -2,6 +2,7 @@
namespace Tests\Feature\Listeners; namespace Tests\Feature\Listeners;
use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction; use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
@ -13,7 +14,6 @@
use App\Models\Notification; use App\Models\Notification;
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome; use App\Services\Publishing\PublishOutcome;
@ -48,8 +48,8 @@ public function test_exception_during_publishing_creates_error_notification(): v
{ {
$routeArticle = $this->createApprovedRouteArticle(); $routeArticle = $this->createApprovedRouteArticle();
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andThrow(new Exception('Connection refused')); ->andThrow(new Exception('Connection refused'));
@ -76,8 +76,8 @@ public function test_no_publication_created_creates_warning_notification(): void
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -106,8 +106,8 @@ public function test_successful_publish_does_not_create_notification(): void
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -131,8 +131,8 @@ public function test_skips_already_published_to_channel(): void
'platform_channel_id' => $routeArticle->platform_channel_id, 'platform_channel_id' => $routeArticle->platform_channel_id,
]); ]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('execute');
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction; use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum; use App\Enums\PublishStatusEnum;
@ -17,7 +18,6 @@
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
@ -124,8 +124,8 @@ public function test_a_skipped_duplicate_is_not_reported_as_a_publish_failure():
PlatformChannelPost::storePost($channel, '555', $article->url, 'Already Posted'); PlatformChannelPost::storePost($channel, '555', $article->url, 'Already Posted');
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$publisher = Mockery::mock(LemmyPublisher::class); $publisher = Mockery::mock(LemmyPublisher::class);
$publisher->shouldNotReceive('publishToChannel'); $publisher->shouldNotReceive('publishToChannel');
@ -141,8 +141,8 @@ public function test_a_genuine_failure_is_still_reported(): void
{ {
[$routeArticle] = $this->fixture; [$routeArticle] = $this->fixture;
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$publisher = Mockery::mock(LemmyPublisher::class); $publisher = Mockery::mock(LemmyPublisher::class);
$publisher->shouldReceive('publishToChannel')->andThrow(new \RuntimeException('Lemmy rejected the post')); $publisher->shouldReceive('publishToChannel')->andThrow(new \RuntimeException('Lemmy rejected the post'));

View file

@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\CreateRouteArticlesAction; use App\Actions\CreateRouteArticlesAction;
use App\Actions\FetchArticleDataAction;
use App\Actions\ValidateArticleAction; use App\Actions\ValidateArticleAction;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Livewire\Articles; use App\Livewire\Articles;
@ -13,7 +14,6 @@
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Models\User; use App\Models\User;
use App\Services\Article\ArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Mockery; use Mockery;
@ -125,8 +125,8 @@ public function test_pending_articles_are_not_stamped_on_creation(): void
private function validate(Article $article): void private function validate(Article $article): void
{ {
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData') $fetcher->shouldReceive('execute')
->with($article) ->with($article)
->once() ->once()
->andReturn([ ->andReturn([

View file

@ -3,6 +3,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\CreateRouteArticlesAction; use App\Actions\CreateRouteArticlesAction;
use App\Actions\FetchArticleDataAction;
use App\Actions\ValidateArticleAction; use App\Actions\ValidateArticleAction;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
@ -13,7 +14,6 @@
use App\Models\Keyword; use App\Models\Keyword;
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
@ -24,8 +24,8 @@ class ValidateArticleListenerTest extends TestCase
private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener
{ {
$articleFetcher = Mockery::mock(ArticleFetcher::class); $articleFetcher = Mockery::mock(FetchArticleDataAction::class);
$articleFetcher->shouldReceive('fetchArticleData')->andReturn( $articleFetcher->shouldReceive('execute')->andReturn(
$content ? [ $content ? [
'title' => 'Test Title', 'title' => 'Test Title',
'description' => 'Test description', 'description' => 'Test description',
@ -111,8 +111,8 @@ public function test_listener_skips_articles_with_existing_publication(): void
public function test_listener_handles_validation_errors_gracefully(): void public function test_listener_handles_validation_errors_gracefully(): void
{ {
$articleFetcher = Mockery::mock(ArticleFetcher::class); $articleFetcher = Mockery::mock(FetchArticleDataAction::class);
$articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed')); $articleFetcher->shouldReceive('execute')->andThrow(new \Exception('Fetch failed'));
$listener = new ValidateArticleListener( $listener = new ValidateArticleListener(
new ValidateArticleAction($articleFetcher, new CreateRouteArticlesAction) new ValidateArticleAction($articleFetcher, new CreateRouteArticlesAction)

View file

@ -2,44 +2,42 @@
namespace Tests\Traits; namespace Tests\Traits;
use App\Actions\FetchArticleDataAction;
use App\Actions\FetchFeedArticlesAction;
use App\Actions\FetchRssArticlesAction; use App\Actions\FetchRssArticlesAction;
use App\Actions\FetchWebsiteArticlesAction; use App\Actions\FetchWebsiteArticlesAction;
use App\Actions\SaveArticleAction; use App\Actions\SaveArticleAction;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Mockery; use Mockery;
use Mockery\MockInterface; use Mockery\MockInterface;
trait CreatesArticleFetcher trait CreatesFetchActions
{ {
protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetcher protected function createFeedFetcher(?LogSaver $logSaver = null): FetchFeedArticlesAction
{
if (! $logSaver) {
$logSaver = $this->mockLogSaver();
}
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
{ {
$logSaver ??= $this->mockLogSaver();
$saveArticle = new SaveArticleAction($logSaver); $saveArticle = new SaveArticleAction($logSaver);
return new ArticleFetcher( return new FetchFeedArticlesAction(
$logSaver, $logSaver,
new FetchRssArticlesAction($logSaver, $saveArticle), new FetchRssArticlesAction($logSaver, $saveArticle),
new FetchWebsiteArticlesAction($logSaver, $saveArticle), new FetchWebsiteArticlesAction($logSaver, $saveArticle),
); );
} }
protected function createArticleDataFetcher(?LogSaver $logSaver = null): FetchArticleDataAction
{
return new FetchArticleDataAction($logSaver ?? $this->mockLogSaver());
}
/** @return array{FetchFeedArticlesAction, MockInterface} */
protected function createFeedFetcherWithMockedLogSaver(): array
{
$logSaver = $this->mockLogSaver();
return [$this->createFeedFetcher($logSaver), $logSaver];
}
/** @return LogSaver&MockInterface */ /** @return LogSaver&MockInterface */
private function mockLogSaver(): MockInterface private function mockLogSaver(): MockInterface
{ {

View file

@ -1,20 +1,19 @@
<?php <?php
namespace Tests\Unit\Services; namespace Tests\Unit\Actions;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher; use Tests\Traits\CreatesFetchActions;
class ArticleFetcherTest extends TestCase class FetchFeedArticlesActionTest extends TestCase
{ {
use CreatesArticleFetcher, RefreshDatabase; use CreatesFetchActions, RefreshDatabase;
protected function setUp(): void protected function setUp(): void
{ {
@ -25,19 +24,18 @@ protected function setUp(): void
'*' => Http::response('<html><body>Mock HTML content</body></html>', 200), '*' => Http::response('<html><body>Mock HTML content</body></html>', 200),
]); ]);
// Create ArticleFetcher only when needed - tests will create their own
} }
public function test_get_articles_from_feed_returns_collection(): void public function test_get_articles_from_feed_returns_collection(): void
{ {
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$feed = Feed::factory()->create([ $feed = Feed::factory()->create([
'type' => 'rss', 'type' => 'rss',
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
]); ]);
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
} }
@ -49,8 +47,8 @@ public function test_get_articles_from_rss_feed_returns_empty_collection(): void
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
// RSS parsing is not implemented yet, should return empty collection // RSS parsing is not implemented yet, should return empty collection
$this->assertEmpty($result); $this->assertEmpty($result);
@ -63,8 +61,8 @@ public function test_get_articles_from_website_feed_handles_no_parser(): void
'url' => 'https://unsupported-site.com/', 'url' => 'https://unsupported-site.com/',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
// Should return empty collection when no parser is available // Should return empty collection when no parser is available
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
@ -78,8 +76,8 @@ public function test_get_articles_from_unsupported_feed_type(): void
'url' => 'https://unsupported-feed-type.com/feed', 'url' => 'https://unsupported-feed-type.com/feed',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -91,8 +89,8 @@ public function test_fetch_article_data_returns_array(): void
'url' => 'https://example.com/article', 'url' => 'https://example.com/article',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createArticleDataFetcher();
$result = $articleFetcher->fetchArticleData($article); $result = $articleFetcher->execute($article);
$this->assertIsArray($result); $this->assertIsArray($result);
// Will be empty array due to unsupported URL in test // Will be empty array due to unsupported URL in test
@ -105,8 +103,8 @@ public function test_fetch_article_data_handles_invalid_url(): void
'url' => 'invalid-url', 'url' => 'invalid-url',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createArticleDataFetcher();
$result = $articleFetcher->fetchArticleData($article); $result = $articleFetcher->execute($article);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -128,8 +126,8 @@ public function test_get_articles_from_feed_with_null_feed_type(): void
$attributes['type'] = 'invalid_type'; $attributes['type'] = 'invalid_type';
$property->setValue($feed, $attributes); $property->setValue($feed, $attributes);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -148,8 +146,8 @@ public function test_get_articles_from_website_feed_with_supported_parser(): voi
]); ]);
// Test actual behavior - VRT parser should be available // Test actual behavior - VRT parser should be available
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
// VRT parser will process the mocked HTML response // VRT parser will process the mocked HTML response
@ -164,8 +162,8 @@ public function test_get_articles_from_website_feed_handles_invalid_url(): void
'url' => 'https://invalid-domain-that-does-not-exist-12345.com/', 'url' => 'https://invalid-domain-that-does-not-exist-12345.com/',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createFeedFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed); $result = $articleFetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -183,8 +181,8 @@ public function test_fetch_article_data_with_supported_parser(): void
]); ]);
// Test actual behavior - VRT parser should be available // Test actual behavior - VRT parser should be available
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createArticleDataFetcher();
$result = $articleFetcher->fetchArticleData($article); $result = $articleFetcher->execute($article);
$this->assertIsArray($result); $this->assertIsArray($result);
// VRT parser will process the mocked HTML response // VRT parser will process the mocked HTML response
@ -196,8 +194,8 @@ public function test_fetch_article_data_handles_unsupported_domain(): void
'url' => 'https://unsupported-domain.com/article', 'url' => 'https://unsupported-domain.com/article',
]); ]);
$articleFetcher = $this->createArticleFetcher(); $articleFetcher = $this->createArticleDataFetcher();
$result = $articleFetcher->fetchArticleData($article); $result = $articleFetcher->execute($article);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertEmpty($result); $this->assertEmpty($result);

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Unit\Services; namespace Tests\Unit\Actions;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
@ -9,11 +9,11 @@
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher; use Tests\Traits\CreatesFetchActions;
class ArticleFetcherRssTest extends TestCase class FetchRssArticlesFeedTest extends TestCase
{ {
use CreatesArticleFetcher, RefreshDatabase; use CreatesFetchActions, RefreshDatabase;
private string $sampleRss; private string $sampleRss;
@ -54,8 +54,8 @@ public function test_get_articles_from_rss_feed_returns_collection(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
} }
@ -70,8 +70,8 @@ public function test_get_articles_from_rss_feed_creates_articles(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertCount(2, $result); $this->assertCount(2, $result);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
@ -99,8 +99,8 @@ public function test_get_articles_from_rss_feed_does_not_duplicate_existing(): v
'feed_id' => $feed->id, 'feed_id' => $feed->id,
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertCount(2, $result); $this->assertCount(2, $result);
$this->assertEquals(1, Article::where('url', 'https://www.theguardian.com/world/2026/mar/08/first-article')->count()); $this->assertEquals(1, Article::where('url', 'https://www.theguardian.com/world/2026/mar/08/first-article')->count());
@ -127,8 +127,8 @@ public function test_get_articles_from_rss_feed_returns_known_articles_when_none
$countBeforeFetch = Article::count(); $countBeforeFetch = Article::count();
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertCount(2, $result); $this->assertCount(2, $result);
$this->assertSame($countBeforeFetch, Article::count()); $this->assertSame($countBeforeFetch, Article::count());
@ -144,8 +144,8 @@ public function test_get_articles_from_rss_feed_handles_invalid_xml(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertInstanceOf(Collection::class, $result); $this->assertInstanceOf(Collection::class, $result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -163,8 +163,8 @@ public function test_get_articles_from_rss_feed_handles_empty_channel(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertEmpty($result); $this->assertEmpty($result);
} }
@ -179,8 +179,8 @@ public function test_get_articles_from_rss_feed_handles_http_failure(): void
'url' => 'https://www.theguardian.com/international/rss', 'url' => 'https://www.theguardian.com/international/rss',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertEmpty($result); $this->assertEmpty($result);
} }
@ -217,8 +217,8 @@ public function test_get_articles_from_belga_rss_feed_creates_articles(): void
'url' => 'https://www.belganewsagency.eu/feed', 'url' => 'https://www.belganewsagency.eu/feed',
]); ]);
$fetcher = $this->createArticleFetcher(); $fetcher = $this->createFeedFetcher();
$result = $fetcher->getArticlesFromFeed($feed); $result = $fetcher->execute($feed);
$this->assertCount(2, $result); $this->assertCount(2, $result);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Unit\Services; namespace Tests\Unit\Actions;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Language; use App\Models\Language;
@ -8,7 +8,7 @@
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher; use Tests\Traits\CreatesFetchActions;
/** /**
* Belga discovery runs through the website path against a JSON API rather than * Belga discovery runs through the website path against a JSON API rather than
@ -16,9 +16,9 @@
* registers a catch-all Http::fake in setUp() that a per-test fake cannot * registers a catch-all Http::fake in setUp() that a per-test fake cannot
* override. * override.
*/ */
class ArticleFetcherBelgaTest extends TestCase class FetchWebsiteArticlesBelgaTest extends TestCase
{ {
use CreatesArticleFetcher, RefreshDatabase; use CreatesFetchActions, RefreshDatabase;
private function apiResponse(): string private function apiResponse(): string
{ {
@ -45,7 +45,7 @@ public function test_creates_articles_from_belga_api_response(): void
{ {
Http::fake(['*' => Http::response($this->apiResponse(), 200)]); Http::fake(['*' => Http::response($this->apiResponse(), 200)]);
$result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); $result = $this->createFeedFetcher()->execute($this->belgaFeed());
$this->assertCount(6, $result); $this->assertCount(6, $result);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
@ -62,7 +62,7 @@ public function test_associates_created_articles_with_the_feed(): void
$feed = $this->belgaFeed(); $feed = $this->belgaFeed();
$this->createArticleFetcher()->getArticlesFromFeed($feed); $this->createFeedFetcher()->execute($feed);
$this->assertDatabaseHas('articles', [ $this->assertDatabaseHas('articles', [
'url' => 'https://www.belganewsagency.eu/press-releases/35285/', 'url' => 'https://www.belganewsagency.eu/press-releases/35285/',
@ -76,7 +76,7 @@ public function test_returns_empty_collection_when_api_returns_no_articles(): vo
// and must be a no-op rather than an error. // and must be a no-op rather than an error.
Http::fake(['*' => Http::response('{"data":[],"_meta":{"total":0}}', 200)]); Http::fake(['*' => Http::response('{"data":[],"_meta":{"total":0}}', 200)]);
$result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); $result = $this->createFeedFetcher()->execute($this->belgaFeed());
$this->assertEmpty($result); $this->assertEmpty($result);
$this->assertDatabaseCount('articles', 0); $this->assertDatabaseCount('articles', 0);
@ -87,7 +87,7 @@ public function test_returns_empty_collection_when_api_returns_an_error_page():
// The failure mode that caused #115: a 404 HTML body reaching the parser. // The failure mode that caused #115: a 404 HTML body reaching the parser.
Http::fake(['*' => Http::response('<html><body>404 Not Found</body></html>', 200)]); Http::fake(['*' => Http::response('<html><body>404 Not Found</body></html>', 200)]);
$result = $this->createArticleFetcher()->getArticlesFromFeed($this->belgaFeed()); $result = $this->createFeedFetcher()->execute($this->belgaFeed());
$this->assertEmpty($result); $this->assertEmpty($result);
} }

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Actions; namespace Tests\Unit\Actions;
use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction; use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum; use App\Enums\PublishStatusEnum;
@ -10,7 +11,6 @@
use App\Models\Feed; use App\Models\Feed;
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome; use App\Services\Publishing\PublishOutcome;
@ -61,8 +61,8 @@ public function test_publish_uses_stored_article_data_without_fetching(): void
'image_url' => 'https://cdn.test/stored.jpg', 'image_url' => 'https://cdn.test/stored.jpg',
]); ]);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')
@ -87,8 +87,8 @@ public function test_publish_body_comes_from_description_not_content(): void
'content' => 'The very much longer full article body text', 'content' => 'The very much longer full article body text',
]); ]);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$captured = null; $captured = null;
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
@ -116,8 +116,8 @@ public function test_publish_falls_back_to_fetching_when_description_is_blank():
$fetched = ['title' => 'Fetched Title', 'description' => 'Fetched description']; $fetched = ['title' => 'Fetched Title', 'description' => 'Fetched description'];
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched); $fetcher->shouldReceive('execute')->once()->andReturn($fetched);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')
@ -141,8 +141,8 @@ public function test_publish_falls_back_to_fetching_when_article_has_no_stored_d
'thumbnail' => 'https://cdn.test/fetched.jpg', 'thumbnail' => 'https://cdn.test/fetched.jpg',
]; ];
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched); $fetcher->shouldReceive('execute')->once()->andReturn($fetched);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')
@ -160,8 +160,8 @@ public function test_publish_fails_when_fallback_fetch_returns_nothing(): void
{ {
$routeArticle = $this->createRouteArticle(['title' => 'Unreachable Article'], unvalidated: true); $routeArticle = $this->createRouteArticle(['title' => 'Unreachable Article'], unvalidated: true);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn([]); $fetcher->shouldReceive('execute')->once()->andReturn([]);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldNotReceive('publishRouteArticle'); $publishingService->shouldNotReceive('publishRouteArticle');
@ -181,8 +181,8 @@ public function test_a_failure_stores_the_reason_and_stops_further_attempts(): v
{ {
$routeArticle = $this->createRouteArticle([], unvalidated: true); $routeArticle = $this->createRouteArticle([], unvalidated: true);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->andReturn([]); $fetcher->shouldReceive('execute')->andReturn([]);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
@ -205,8 +205,8 @@ public function test_a_successful_publish_clears_an_earlier_failure(): void
]); ]);
$routeArticle->recordPublishFailed('an earlier failure'); $routeArticle->recordPublishFailed('an earlier failure');
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')
@ -226,8 +226,8 @@ public function test_publish_fails_when_fallback_recovers_only_a_title(): void
{ {
$routeArticle = $this->createRouteArticle([], unvalidated: true); $routeArticle = $this->createRouteArticle([], unvalidated: true);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn(['title' => 'Recovered Title']); $fetcher->shouldReceive('execute')->once()->andReturn(['title' => 'Recovered Title']);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldNotReceive('publishRouteArticle'); $publishingService->shouldNotReceive('publishRouteArticle');
@ -245,8 +245,8 @@ public function test_publish_succeeds_when_fallback_returns_a_description_withou
$fetched = ['title' => 'Recovered Title', 'description' => 'Recovered description']; $fetched = ['title' => 'Recovered Title', 'description' => 'Recovered description'];
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched); $fetcher->shouldReceive('execute')->once()->andReturn($fetched);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')
@ -268,8 +268,8 @@ public function test_publish_does_not_refetch_for_articles_stored_before_image_u
'image_url' => null, 'image_url' => null,
]); ]);
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(FetchArticleDataAction::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('execute');
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$publishingService->shouldReceive('publishRouteArticle') $publishingService->shouldReceive('publishRouteArticle')

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Actions; namespace Tests\Unit\Actions;
use App\Actions\CreateRouteArticlesAction; use App\Actions\CreateRouteArticlesAction;
use App\Actions\FetchArticleDataAction;
use App\Actions\ValidateArticleAction; use App\Actions\ValidateArticleAction;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Models\Article; use App\Models\Article;
@ -12,7 +13,6 @@
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
use Mockery\MockInterface; use Mockery\MockInterface;
@ -29,7 +29,7 @@ class ValidateArticleActionTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->articleFetcher = Mockery::mock(ArticleFetcher::class); $this->articleFetcher = Mockery::mock(FetchArticleDataAction::class);
$this->validateArticle = new ValidateArticleAction($this->articleFetcher, new CreateRouteArticlesAction); $this->validateArticle = new ValidateArticleAction($this->articleFetcher, new CreateRouteArticlesAction);
} }
@ -56,7 +56,7 @@ private function mockFetchReturning(Article $article, ?string $content, ?string
} }
$this->articleFetcher $this->articleFetcher
->shouldReceive('fetchArticleData') ->shouldReceive('execute')
->with($article) ->with($article)
->once() ->once()
->andReturn($data); ->andReturn($data);

View file

@ -2,10 +2,10 @@
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Actions\FetchFeedArticlesAction;
use App\Jobs\ArticleDiscoveryForFeedJob; use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -63,9 +63,9 @@ public function test_handle_fetches_articles_and_updates_feed(): void
$mockArticles = collect(['article1', 'article2']); $mockArticles = collect(['article1', 'article2']);
// Mock ArticleFetcher // Mock the feed fetch
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchFeedArticlesAction::class);
$articleFetcherMock->shouldReceive('getArticlesFromFeed') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->with($feed) ->with($feed)
->andReturn($mockArticles); ->andReturn($mockArticles);
@ -189,9 +189,9 @@ public function test_handle_logs_start_message_with_correct_context(): void
$mockArticles = collect([new Article]); $mockArticles = collect([new Article]);
// Mock ArticleFetcher // Mock the feed fetch
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchFeedArticlesAction::class);
$articleFetcherMock->shouldReceive('getArticlesFromFeed') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($mockArticles); ->andReturn($mockArticles);

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction; use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
@ -15,7 +16,6 @@
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome; use App\Services\Publishing\PublishOutcome;
@ -94,7 +94,7 @@ public function test_job_uses_queueable_trait(): void
public function test_handle_returns_early_when_no_approved_route_articles(): void public function test_handle_returns_early_when_no_approved_route_articles(): void
{ {
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -113,7 +113,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_route_art
'platform_channel_id' => $routeArticle->platform_channel_id, 'platform_channel_id' => $routeArticle->platform_channel_id,
]); ]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -131,7 +131,7 @@ public function test_handle_skips_non_approved_route_articles(): void
RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]); RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -160,8 +160,8 @@ public function test_handle_publishes_oldest_approved_route_article(): void
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id)) ->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id))
->andReturn($extractedData); ->andReturn($extractedData);
@ -189,8 +189,8 @@ public function test_handle_throws_exception_on_publishing_failure(): void
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$publishException = new PublishException($article, null); $publishException = new PublishException($article, null);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -215,10 +215,10 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
]); ]);
Setting::setArticlePublishingInterval(10); Setting::setArticlePublishingInterval(10);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('execute');
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -235,10 +235,10 @@ public function test_handle_skips_publishing_when_daily_cap_reached(): void
Setting::setArticlePublishingInterval(0); Setting::setArticlePublishingInterval(0);
Setting::setDailyPublishCap(3); Setting::setDailyPublishCap(3);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('execute');
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -255,8 +255,8 @@ public function test_handle_publishes_when_below_daily_cap(): void
Setting::setArticlePublishingInterval(0); Setting::setArticlePublishingInterval(0);
Setting::setDailyPublishCap(3); Setting::setDailyPublishCap(3);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn(['title' => 'Test Article', 'description' => 'Test description']); ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
@ -279,8 +279,8 @@ public function test_handle_publishes_when_daily_cap_is_zero(): void
Setting::setArticlePublishingInterval(0); Setting::setArticlePublishingInterval(0);
Setting::setDailyPublishCap(0); Setting::setDailyPublishCap(0);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn(['title' => 'Test Article', 'description' => 'Test description']); ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
@ -307,10 +307,10 @@ public function test_daily_cap_counts_each_channel_publication_separately(): voi
Setting::setArticlePublishingInterval(0); Setting::setArticlePublishingInterval(0);
Setting::setDailyPublishCap(3); Setting::setDailyPublishCap(3);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('execute');
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
@ -327,8 +327,8 @@ public function test_handle_ignores_publications_from_previous_days(): void
Setting::setArticlePublishingInterval(0); Setting::setArticlePublishingInterval(0);
Setting::setDailyPublishCap(3); Setting::setDailyPublishCap(3);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn(['title' => 'Test Article', 'description' => 'Test description']); ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
@ -354,8 +354,8 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -381,8 +381,8 @@ public function test_handle_publishes_when_interval_is_zero(): void
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -408,8 +408,8 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -432,8 +432,8 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
$extractedData = ['title' => 'Test Article', 'description' => 'Test description']; $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -454,8 +454,8 @@ public function test_handle_creates_warning_notification_when_no_publication_cre
$extractedData = ['title' => 'No Route Article', 'description' => 'Test description']; $extractedData = ['title' => 'No Route Article', 'description' => 'Test description'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -486,8 +486,8 @@ public function test_handle_creates_notification_on_publish_exception(): void
$extractedData = ['title' => 'Failing Article', 'description' => 'Test description']; $extractedData = ['title' => 'Failing Article', 'description' => 'Test description'];
$publishException = new PublishException($article, null); $publishException = new PublishException($article, null);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn($extractedData); ->andReturn($extractedData);
@ -520,8 +520,8 @@ public function test_handle_skips_route_articles_that_previously_failed(): void
$routeArticle = $this->createApprovedRouteArticle(); $routeArticle = $this->createApprovedRouteArticle();
$routeArticle->recordPublishFailed('couldnt_find_community'); $routeArticle->recordPublishFailed('couldnt_find_community');
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('execute');
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
@ -541,8 +541,8 @@ public function test_handle_publishes_a_later_article_when_the_oldest_has_failed
$next = $this->createApprovedRouteArticle(['title' => 'Next Article']); $next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
$next->update(['created_at' => now()->subDay()]); $next->update(['created_at' => now()->subDay()]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('execute')
->once() ->once()
->andReturn(['title' => 'Next Article', 'description' => 'Test description']); ->andReturn(['title' => 'Next Article', 'description' => 'Test description']);