2026-08-09 20:41:07 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Tests\Unit\Actions;
|
|
|
|
|
|
|
|
|
|
use App\Actions\PublishRouteArticleAction;
|
2026-08-09 23:28:59 +02:00
|
|
|
use App\Enums\NotificationTypeEnum;
|
2026-08-09 20:41:07 +02:00
|
|
|
use App\Enums\PublishStatusEnum;
|
|
|
|
|
use App\Models\Article;
|
|
|
|
|
use App\Models\ArticlePublication;
|
|
|
|
|
use App\Models\Feed;
|
|
|
|
|
use App\Models\Route;
|
|
|
|
|
use App\Models\RouteArticle;
|
|
|
|
|
use App\Services\Article\ArticleFetcher;
|
|
|
|
|
use App\Services\Notification\NotificationService;
|
|
|
|
|
use App\Services\Publishing\ArticlePublishingService;
|
|
|
|
|
use App\Services\Publishing\PublishOutcome;
|
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
use Mockery;
|
|
|
|
|
use Tests\TestCase;
|
|
|
|
|
|
|
|
|
|
class PublishRouteArticleActionTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
use RefreshDatabase;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $articleAttributes
|
|
|
|
|
*/
|
|
|
|
|
private function createRouteArticle(array $articleAttributes, bool $unvalidated = false): RouteArticle
|
|
|
|
|
{
|
|
|
|
|
$feed = Feed::factory()->create();
|
|
|
|
|
/** @var Route $route */
|
|
|
|
|
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
|
|
|
|
|
|
|
|
|
$factory = Article::factory();
|
|
|
|
|
if ($unvalidated) {
|
|
|
|
|
$factory = $factory->unvalidated();
|
|
|
|
|
}
|
|
|
|
|
$article = $factory->create(array_merge(['feed_id' => $feed->id], $articleAttributes));
|
|
|
|
|
|
|
|
|
|
/** @var RouteArticle $routeArticle */
|
|
|
|
|
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
|
|
|
|
'article_id' => $article->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $routeArticle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function makePublication(): ArticlePublication
|
|
|
|
|
{
|
|
|
|
|
/** @var ArticlePublication $publication */
|
|
|
|
|
$publication = ArticlePublication::factory()->create();
|
|
|
|
|
|
|
|
|
|
return $publication;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_uses_stored_article_data_without_fetching(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([
|
|
|
|
|
'title' => 'Stored Title',
|
|
|
|
|
'description' => 'Stored description',
|
|
|
|
|
'image_url' => 'https://cdn.test/stored.jpg',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldNotReceive('fetchArticleData');
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->with(Mockery::any(), [
|
|
|
|
|
'title' => 'Stored Title',
|
|
|
|
|
'description' => 'Stored description',
|
|
|
|
|
'thumbnail' => 'https://cdn.test/stored.jpg',
|
|
|
|
|
])
|
|
|
|
|
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_body_comes_from_description_not_content(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([
|
|
|
|
|
'description' => 'The short description',
|
|
|
|
|
'content' => 'The very much longer full article body text',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldNotReceive('fetchArticleData');
|
|
|
|
|
|
|
|
|
|
$captured = null;
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->andReturnUsing(function ($ra, $data) use (&$captured) {
|
|
|
|
|
$captured = $data;
|
|
|
|
|
|
|
|
|
|
return PublishOutcome::published($this->makePublication());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('The short description', $captured['description']);
|
|
|
|
|
$this->assertArrayNotHasKey('content', $captured);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_falls_back_to_fetching_when_description_is_blank(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([
|
|
|
|
|
'description' => '',
|
|
|
|
|
'image_url' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$fetched = ['title' => 'Fetched Title', 'description' => 'Fetched description'];
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched);
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->with(Mockery::any(), $fetched)
|
|
|
|
|
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_falls_back_to_fetching_when_article_has_no_stored_data(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
|
|
|
|
|
|
|
|
|
$fetched = [
|
|
|
|
|
'title' => 'Fetched Title',
|
|
|
|
|
'description' => 'Fetched description',
|
|
|
|
|
'thumbnail' => 'https://cdn.test/fetched.jpg',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched);
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->with(Mockery::any(), $fetched)
|
|
|
|
|
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 23:28:59 +02:00
|
|
|
public function test_publish_fails_when_fallback_fetch_returns_nothing(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle(['title' => 'Unreachable Article'], unvalidated: true);
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn([]);
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldNotReceive('publishRouteArticle');
|
|
|
|
|
|
|
|
|
|
$outcome = (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($outcome->failed());
|
|
|
|
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
|
|
|
|
|
$this->assertDatabaseCount('article_publications', 0);
|
|
|
|
|
$this->assertDatabaseHas('notifications', [
|
|
|
|
|
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_fails_when_fallback_recovers_only_a_title(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn(['title' => 'Recovered Title']);
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldNotReceive('publishRouteArticle');
|
|
|
|
|
|
|
|
|
|
$outcome = (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($outcome->failed());
|
|
|
|
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function test_publish_succeeds_when_fallback_returns_a_description_without_a_thumbnail(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([], unvalidated: true);
|
|
|
|
|
|
|
|
|
|
$fetched = ['title' => 'Recovered Title', 'description' => 'Recovered description'];
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldReceive('fetchArticleData')->once()->andReturn($fetched);
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->with(Mockery::any(), $fetched)
|
|
|
|
|
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 20:41:07 +02:00
|
|
|
public function test_publish_does_not_refetch_for_articles_stored_before_image_url_existed(): void
|
|
|
|
|
{
|
|
|
|
|
$routeArticle = $this->createRouteArticle([
|
|
|
|
|
'title' => 'Legacy Title',
|
|
|
|
|
'description' => 'Legacy description',
|
|
|
|
|
'image_url' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
|
|
|
|
$fetcher->shouldNotReceive('fetchArticleData');
|
|
|
|
|
|
|
|
|
|
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
|
|
|
|
$publishingService->shouldReceive('publishRouteArticle')
|
|
|
|
|
->once()
|
|
|
|
|
->with(Mockery::any(), [
|
|
|
|
|
'title' => 'Legacy Title',
|
|
|
|
|
'description' => 'Legacy description',
|
|
|
|
|
'thumbnail' => null,
|
|
|
|
|
])
|
|
|
|
|
->andReturn(PublishOutcome::published($this->makePublication()));
|
|
|
|
|
|
|
|
|
|
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
|
|
|
|
->execute($routeArticle);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
|
|
|
|
|
}
|
|
|
|
|
}
|