diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 96c6cda..99239dd 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -3,12 +3,15 @@ namespace App\Jobs; use App\Enums\LogLevelEnum; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Setting; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -33,7 +36,7 @@ public function __construct() * * @throws PublishException */ - public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void + public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void { $interval = Setting::getArticlePublishingInterval(); @@ -62,22 +65,43 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService 'created_at' => $article->created_at, ]); - // Fetch article data - $extractedData = $articleFetcher->fetchArticleData($article); - try { - $publishingService->publishToRoutedChannels($article, $extractedData); + $extractedData = $articleFetcher->fetchArticleData($article); + $publications = $publishingService->publishToRoutedChannels($article, $extractedData); - ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ - 'article_id' => $article->id, - 'title' => $article->title, - ]); + if ($publications->isNotEmpty()) { + ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + } else { + ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + + $notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::WARNING, + "Publish failed: {$article->title}", + 'No publications were created for this article. Check channel routing configuration.', + $article, + ); + } } catch (PublishException $e) { ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), ]); + $notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::ERROR, + "Publish failed: {$article->title}", + $e->getMessage(), + $article, + ); + throw $e; } } diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index f957809..aab3ddf 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -3,9 +3,12 @@ namespace App\Listeners; use App\Enums\LogLevelEnum; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; use App\Events\ArticleApproved; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; @@ -16,7 +19,8 @@ class PublishApprovedArticleListener implements ShouldQueue public function __construct( private ArticleFetcher $articleFetcher, - private ArticlePublishingService $publishingService + private ArticlePublishingService $publishingService, + private NotificationService $notificationService, ) {} public function handle(ArticleApproved $event): void @@ -53,6 +57,14 @@ public function handle(ArticleApproved $event): void 'article_id' => $article->id, 'title' => $article->title, ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::WARNING, + "Publish failed: {$article->title}", + 'No publications were created for this article. Check channel routing configuration.', + $article, + ); } } catch (Exception $e) { $article->update(['publish_status' => 'error']); @@ -61,6 +73,14 @@ public function handle(ArticleApproved $event): void 'article_id' => $article->id, 'error' => $e->getMessage(), ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::ERROR, + "Publish failed: {$article->title}", + $e->getMessage(), + $article, + ); } } } diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php new file mode 100644 index 0000000..cf1ffb1 --- /dev/null +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -0,0 +1,123 @@ +create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andThrow(new Exception('Connection refused')); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $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('Test Article', $notification->title); + $this->assertStringContainsString('Connection refused', $notification->message); + } + + public function test_no_publications_created_creates_warning_notification(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $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); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $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('Test Article', $notification->title); + } + + public function test_successful_publish_does_not_create_notification(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $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'])); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $this->assertDatabaseCount('notifications', 0); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 46cdf3c..3d77b50 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -2,15 +2,20 @@ 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\Setting; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -18,9 +23,12 @@ class PublishNextArticleJobTest extends TestCase { use RefreshDatabase; + private NotificationService $notificationService; + protected function setUp(): void { parent::setUp(); + $this->notificationService = new NotificationService; } public function test_constructor_sets_correct_queue(): void @@ -71,7 +79,7 @@ public function test_handle_returns_early_when_no_approved_articles(): void // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); @@ -96,7 +104,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_articles( // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); @@ -122,7 +130,7 @@ public function test_handle_skips_non_approved_articles(): void // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error (no approved articles to process) $this->assertTrue(true); @@ -167,12 +175,13 @@ public function test_handle_publishes_oldest_approved_article(): void return $article->id === $olderArticle->id; }), $extractedData - ); + ) + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations are verified in tearDown $this->assertTrue(true); @@ -209,7 +218,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void $this->expectException(PublishException::class); // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); } public function test_handle_logs_publishing_start(): void @@ -233,12 +242,14 @@ public function test_handle_logs_publishing_start(): void // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels')->once(); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Verify the job completes (logging is verified by observing no exceptions) $this->assertTrue(true); @@ -278,12 +289,13 @@ public function test_handle_fetches_article_data_before_publishing(): void $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() - ->with(Mockery::type(Article::class), $extractedData); + ->with(Mockery::type(Article::class), $extractedData) + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations verified in tearDown $this->assertTrue(true); @@ -311,7 +323,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -339,10 +351,11 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -370,10 +383,11 @@ public function test_handle_publishes_when_interval_is_zero(): void $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -401,10 +415,11 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -428,14 +443,91 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $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();