fedi-feed-router/tests/Unit/Actions/PublishRouteArticleActionTest.php

244 lines
9.3 KiB
PHP

<?php
namespace Tests\Unit\Actions;
use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationTypeEnum;
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);
}
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);
}
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);
}
}