notificationService = new NotificationService; } 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( \Illuminate\Foundation\Queue\Queueable::class, class_uses($job) ); } public function test_handle_returns_early_when_no_approved_articles(): void { // Arrange - No articles exist $articleFetcherMock = Mockery::mock(ArticleFetcher::class); // No expectations as handle should return early $job = new PublishNextArticleJob; // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); } public function test_handle_returns_early_when_no_unpublished_approved_articles(): void { // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); // Create a publication record to mark it as already published ArticlePublication::factory()->create(['article_id' => $article->id]); $articleFetcherMock = Mockery::mock(ArticleFetcher::class); // No expectations as handle should return early $job = new PublishNextArticleJob; // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); } public function test_handle_skips_non_approved_articles(): void { // Arrange $feed = Feed::factory()->create(); Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'pending', ]); Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'rejected', ]); $articleFetcherMock = Mockery::mock(ArticleFetcher::class); // No expectations as handle should return early $job = new PublishNextArticleJob; // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error (no approved articles to process) $this->assertTrue(true); } public function test_handle_publishes_oldest_approved_article(): void { // Arrange $feed = Feed::factory()->create(); // Create older article first $olderArticle = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', 'created_at' => now()->subHours(2), ]); // Create newer article $newerArticle = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', 'created_at' => now()->subHour(), ]); $extractedData = ['title' => 'Test Article', 'content' => 'Test content']; // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() ->with(Mockery::on(function ($article) use ($olderArticle) { return $article->id === $olderArticle->id; })) ->andReturn($extractedData); // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() ->with( Mockery::on(function ($article) use ($olderArticle) { return $article->id === $olderArticle->id; }), $extractedData ) ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations are verified in tearDown $this->assertTrue(true); } public function test_handle_throws_exception_on_publishing_failure(): void { // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); $extractedData = ['title' => 'Test Article']; $publishException = new PublishException($article, null); // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() ->with(Mockery::type(Article::class)) ->andReturn($extractedData); // Mock ArticlePublishingService to throw exception $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() ->andThrow($publishException); $job = new PublishNextArticleJob; // Assert $this->expectException(PublishException::class); // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); } public function test_handle_logs_publishing_start(): void { // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', 'title' => 'Test Article Title', 'url' => 'https://example.com/article', ]); $extractedData = ['title' => 'Test Article']; // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() ->andReturn($extractedData); // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Verify the job completes (logging is verified by observing no exceptions) $this->assertTrue(true); } 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); } public function test_handle_fetches_article_data_before_publishing(): void { // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); $extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content']; // Mock ArticleFetcher with specific expectations $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() ->with(Mockery::type(Article::class)) ->andReturn($extractedData); // Mock publishing service to receive the extracted data $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() ->with(Mockery::type(Article::class), $extractedData) ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations verified in tearDown $this->assertTrue(true); } public function test_handle_skips_publishing_when_last_publication_within_interval(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); // Last publication was 3 minutes ago, interval is 10 minutes ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(3), ]); Setting::setArticlePublishingInterval(10); $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); // Neither should be called $articleFetcherMock->shouldNotReceive('fetchArticleData'); $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } public function test_handle_publishes_when_last_publication_beyond_interval(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); // Last publication was 15 minutes ago, interval is 10 minutes 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('publishToRoutedChannels') ->once() ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } public function test_handle_publishes_when_interval_is_zero(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); // Last publication was just now, but interval is 0 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('publishToRoutedChannels') ->once() ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } public function test_handle_publishes_when_last_publication_exactly_at_interval(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish 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('publishToRoutedChannels') ->once() ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } public function test_handle_publishes_when_no_previous_publications_exist(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', ]); 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('publishToRoutedChannels') ->once() ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } public function test_handle_creates_warning_notification_when_no_publications_created(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', '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('publishToRoutedChannels') ->once() ->andReturn(new Collection); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::WARNING->value, 'notifiable_type' => $article->getMorphClass(), 'notifiable_id' => $article->id, ]); $notification = Notification::first(); $this->assertStringContainsString('No Route Article', $notification->title); } public function test_handle_creates_notification_on_publish_exception(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'approved', 'title' => 'Failing 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('publishToRoutedChannels') ->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); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } }