notificationService = new NotificationService; } /** * @param array $articleOverrides * @param array $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(ShouldQueue::class, $job); } public function test_job_implements_should_be_unique(): void { $job = new PublishNextArticleJob; $this->assertInstanceOf(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(); } }