fedi-feed-router/tests/Unit/Jobs/PublishNextArticleJobTest.php

414 lines
14 KiB
PHP

<?php
namespace Tests\Unit\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Exceptions\PublishException;
use App\Jobs\PublishNextArticleJob;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PublishNextArticleJobTest extends TestCase
{
use RefreshDatabase;
private NotificationService $notificationService;
protected function setUp(): void
{
parent::setUp();
$this->notificationService = new NotificationService;
}
/**
* @param array<string, mixed> $articleOverrides
* @param array<string, mixed> $routeOverrides
*/
private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
$article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $article->id,
]);
return $routeArticle;
}
public function test_constructor_sets_correct_queue(): void
{
$job = new PublishNextArticleJob;
$this->assertEquals('publishing', $job->queue);
}
public function test_job_implements_should_queue(): void
{
$job = new PublishNextArticleJob;
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job);
}
public function test_job_implements_should_be_unique(): void
{
$job = new PublishNextArticleJob;
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
}
public function test_job_has_unique_for_property(): void
{
$job = new PublishNextArticleJob;
$this->assertEquals(300, $job->uniqueFor);
}
public function test_job_uses_queueable_trait(): void
{
$job = new PublishNextArticleJob;
$this->assertContains(Queueable::class, class_uses($job));
}
public function test_handle_returns_early_when_no_approved_route_articles(): void
{
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void
{
$routeArticle = $this->createApprovedRouteArticle();
// Mark the article as already published to this channel
ArticlePublication::factory()->create([
'article_id' => $routeArticle->article_id,
'platform_channel_id' => $routeArticle->platform_channel_id,
]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_skips_non_approved_route_articles(): void
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create(['feed_id' => $feed->id]);
RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_oldest_approved_route_article(): void
{
$feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$olderArticle = Article::factory()->create(['feed_id' => $feed->id]);
$newerArticle = Article::factory()->create(['feed_id' => $feed->id]);
RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $olderArticle->id,
'created_at' => now()->subHours(2),
]);
RouteArticle::factory()->forRoute($route)->approved()->create([
'article_id' => $newerArticle->id,
'created_at' => now()->subHour(),
]);
$extractedData = ['title' => 'Test Article', 'content' => 'Test content'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id))
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->with(
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
$extractedData
)
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_throws_exception_on_publishing_failure(): void
{
$routeArticle = $this->createApprovedRouteArticle();
$article = $routeArticle->article;
$extractedData = ['title' => 'Test Article'];
$publishException = new PublishException($article, null);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andThrow($publishException);
$job = new PublishNextArticleJob;
$this->expectException(PublishException::class);
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
}
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(3),
]);
Setting::setArticlePublishingInterval(10);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_beyond_interval(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(15),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_interval_is_zero(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now(),
]);
Setting::setArticlePublishingInterval(0);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
{
$this->createApprovedRouteArticle();
ArticlePublication::factory()->create([
'published_at' => now()->subMinutes(10),
]);
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_publishes_when_no_previous_publications_exist(): void
{
$this->createApprovedRouteArticle();
Setting::setArticlePublishingInterval(10);
$extractedData = ['title' => 'Test Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(ArticlePublication::factory()->make());
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertTrue(true);
}
public function test_handle_creates_warning_notification_when_no_publication_created(): void
{
$routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']);
$extractedData = ['title' => 'No Route Article'];
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(null);
$job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $routeArticle->article_id,
]);
$notification = Notification::first();
$this->assertStringContainsString('No Route Article', $notification->title);
}
public function test_handle_creates_notification_on_publish_exception(): void
{
$routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']);
$article = $routeArticle->article;
$extractedData = ['title' => 'Failing Article'];
$publishException = new PublishException($article, null);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData')
->once()
->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andThrow($publishException);
$job = new PublishNextArticleJob;
try {
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
} catch (PublishException) {
// Expected
}
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::ERROR->value,
'notifiable_type' => $article->getMorphClass(),
'notifiable_id' => $article->id,
]);
$notification = Notification::first();
$this->assertStringContainsString('Failing Article', $notification->title);
}
public function test_job_can_be_serialized(): void
{
$job = new PublishNextArticleJob;
$serialized = serialize($job);
$unserialized = unserialize($serialized);
$this->assertInstanceOf(PublishNextArticleJob::class, $unserialized);
$this->assertEquals($job->queue, $unserialized->queue);
$this->assertEquals($job->uniqueFor, $unserialized->uniqueFor);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}