diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 99239dd..c0fd39d 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -2,13 +2,14 @@ namespace App\Jobs; +use App\Enums\ApprovalStatusEnum; 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\RouteArticle; use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; @@ -48,34 +49,39 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService } } - // Get the oldest approved article that hasn't been published yet - $article = Article::where('approval_status', 'approved') - ->whereDoesntHave('articlePublication') - ->oldest('created_at') + // Get the oldest approved route_article that hasn't been published to its channel yet + $routeArticle = RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED) + ->whereDoesntHave('article.articlePublications', function ($query) { + $query->whereColumn('article_publications.platform_channel_id', 'route_articles.platform_channel_id'); + }) + ->oldest('route_articles.created_at') + ->with(['article', 'platformChannel.platformInstance', 'platformChannel.activePlatformAccounts']) ->first(); - if (! $article) { + if (! $routeArticle) { return; } + $article = $routeArticle->article; + ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, 'url' => $article->url, - 'created_at' => $article->created_at, + 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id, ]); try { $extractedData = $articleFetcher->fetchArticleData($article); - $publications = $publishingService->publishToRoutedChannels($article, $extractedData); + $publication = $publishingService->publishRouteArticle($routeArticle, $extractedData); - if ($publications->isNotEmpty()) { + if ($publication) { ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { - ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [ + ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, ]); @@ -84,7 +90,7 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService NotificationTypeEnum::PUBLISH_FAILED, NotificationSeverityEnum::WARNING, "Publish failed: {$article->title}", - 'No publications were created for this article. Check channel routing configuration.', + 'No publication was created for this article. Check channel routing configuration.', $article, ); } diff --git a/app/Models/Article.php b/app/Models/Article.php index 96b3f74..bb834a0 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -30,6 +30,7 @@ * @property Carbon $created_at * @property Carbon $updated_at * @property ArticlePublication|null $articlePublication + * @property \Illuminate\Support\HigherOrderCollectionProxy|mixed $routeArticles */ class Article extends Model { @@ -130,6 +131,14 @@ public function articlePublication(): HasOne return $this->hasOne(ArticlePublication::class); } + /** + * @return HasMany + */ + public function articlePublications(): HasMany + { + return $this->hasMany(ArticlePublication::class); + } + /** * @return BelongsTo */ diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index 7634137..1d8d271 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -6,9 +6,11 @@ use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; +use App\Models\Keyword; use App\Models\PlatformChannel; use App\Models\PlatformChannelPost; use App\Models\Route; +use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use Exception; @@ -28,6 +30,42 @@ protected function makePublisher(mixed $account): LemmyPublisher } /** + * Publish an article to the channel specified by a route_article record. + * + * @param array $extractedData + * + * @throws PublishException + */ + public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication + { + $article = $routeArticle->article; + $channel = $routeArticle->platformChannel; + + if (! $channel) { + throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('ROUTE_ARTICLE_MISSING_CHANNEL')); + } + + if (! $channel->relationLoaded('platformInstance')) { + $channel->load(['platformInstance', 'activePlatformAccounts']); + } + + $account = $channel->activePlatformAccounts()->first(); + + if (! $account) { + $this->logSaver->warning('No active account for channel', $channel, [ + 'article_id' => $article->id, + 'route_article_id' => $routeArticle->id, + ]); + + return null; + } + + return $this->publishToChannel($article, $extractedData, $channel, $account); + } + + /** + * @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility. + * * @param array $extractedData * @return Collection * @@ -41,21 +79,37 @@ public function publishToRoutedChannels(Article $article, array $extractedData): $feed = $article->feed; - // Get active routes with keywords instead of just channels $activeRoutes = Route::where('feed_id', $feed->id) ->where('is_active', true) - ->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords']) - ->orderBy('priority', 'desc') ->get(); - // Filter routes based on keyword matches - $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) { - return $this->routeMatchesArticle($route, $extractedData); + $keywordsByChannel = Keyword::where('feed_id', $feed->id) + ->where('is_active', true) + ->get() + ->groupBy('platform_channel_id'); + + $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData, $keywordsByChannel) { + $keywords = $keywordsByChannel->get($route->platform_channel_id, collect()); + if ($keywords->isEmpty()) { + return true; + } + + $articleContent = ($extractedData['full_article'] ?? ''). + ' '.($extractedData['title'] ?? ''). + ' '.($extractedData['description'] ?? ''); + + foreach ($keywords as $keyword) { + if (stripos($articleContent, $keyword->keyword) !== false) { + return true; + } + } + + return false; }); return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { - $channel = $route->platformChannel; - $account = $channel->activePlatformAccounts()->first(); + $channel = PlatformChannel::with(['platformInstance', 'activePlatformAccounts'])->find($route->platform_channel_id); + $account = $channel?->activePlatformAccounts()->first(); if (! $account) { $this->logSaver->warning('No active account for channel', $channel, [ @@ -67,46 +121,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData): } return $this->publishToChannel($article, $extractedData, $channel, $account); - }) - ->filter(); - } - - /** - * Check if a route matches an article based on keywords - * - * @param array $extractedData - */ - private function routeMatchesArticle(Route $route, array $extractedData): bool - { - // Get active keywords for this route - $activeKeywords = $route->keywords->where('is_active', true); - - // If no keywords are defined for this route, the route matches any article - if ($activeKeywords->isEmpty()) { - return true; - } - - // Get article content for keyword matching - $articleContent = ''; - if (isset($extractedData['full_article'])) { - $articleContent = $extractedData['full_article']; - } - if (isset($extractedData['title'])) { - $articleContent .= ' '.$extractedData['title']; - } - if (isset($extractedData['description'])) { - $articleContent .= ' '.$extractedData['description']; - } - - // Check if any of the route's keywords match the article content - foreach ($activeKeywords as $keywordModel) { - $keyword = $keywordModel->keyword; - if (stripos($articleContent, $keyword) !== false) { - return true; - } - } - - return false; + })->filter(); } /** @@ -145,7 +160,7 @@ private function publishToChannel(Article $article, array $extractedData, Platfo 'publication_data' => $postData, ]); - $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ + $this->logSaver->info('Published to channel', $channel, [ 'article_id' => $article->id, ]); diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 3d77b50..e37079a 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -10,12 +10,14 @@ 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 Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -31,6 +33,17 @@ protected function setUp(): void $this->notificationService = new NotificationService; } + private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides)); + $article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides)); + + return RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + } + public function test_constructor_sets_correct_queue(): void { $job = new PublishNextArticleJob; @@ -63,253 +76,125 @@ public function test_job_uses_queueable_trait(): void { $job = new PublishNextArticleJob; - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); + $this->assertContains(Queueable::class, class_uses($job)); } - public function test_handle_returns_early_when_no_approved_articles(): void + public function test_handle_returns_early_when_no_approved_route_articles(): void { - // Arrange - No articles exist $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $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 + public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $routeArticle = $this->createApprovedRouteArticle(); - // 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', + // 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); - // No expectations as handle should return early + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $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 + public function test_handle_skips_non_approved_route_articles(): void { - // Arrange $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id]); - // Create older article first - $olderArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 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(); + $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), ]); - - // Create newer article - $newerArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', + RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $newerArticle->id, '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; - })) + ->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id)) ->andReturn($extractedData); - // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() ->with( - Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - }), + Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id), $extractedData ) - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $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', - ]); + $routeArticle = $this->createApprovedRouteArticle(); + $article = $routeArticle->article; $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') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->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', - ]); + $this->createApprovedRouteArticle(); - // Last publication was 3 minutes ago, interval is 10 minutes ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(3), ]); @@ -318,9 +203,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - // Neither should be called $articleFetcherMock->shouldNotReceive('fetchArticleData'); - $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -330,13 +214,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv 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', - ]); + $this->createApprovedRouteArticle(); - // Last publication was 15 minutes ago, interval is 10 minutes ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(15), ]); @@ -350,9 +229,9 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -362,13 +241,8 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v 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', - ]); + $this->createApprovedRouteArticle(); - // Last publication was just now, but interval is 0 ArticlePublication::factory()->create([ 'published_at' => now(), ]); @@ -382,9 +256,9 @@ public function test_handle_publishes_when_interval_is_zero(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->andReturn(new Collection(['publication'])); + $publishingServiceMock->shouldReceive('publishRouteArticle') + ->once() + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -394,13 +268,8 @@ public function test_handle_publishes_when_interval_is_zero(): void 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', - ]); + $this->createApprovedRouteArticle(); - // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(10), ]); @@ -414,9 +283,9 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -426,11 +295,7 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( 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', - ]); + $this->createApprovedRouteArticle(); Setting::setArticlePublishingInterval(10); @@ -442,9 +307,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -452,14 +317,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi $this->assertTrue(true); } - public function test_handle_creates_warning_notification_when_no_publications_created(): void + public function test_handle_creates_warning_notification_when_no_publication_created(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'No Route Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']); $extractedData = ['title' => 'No Route Article']; @@ -469,9 +329,9 @@ public function test_handle_creates_warning_notification_when_no_publications_cr ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection); + ->andReturn(null); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -479,8 +339,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::WARNING->value, - 'notifiable_type' => $article->getMorphClass(), - 'notifiable_id' => $article->id, + 'notifiable_type' => $routeArticle->article->getMorphClass(), + 'notifiable_id' => $routeArticle->article_id, ]); $notification = Notification::first(); @@ -489,12 +349,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr 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', - ]); + $routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']); + $article = $routeArticle->article; $extractedData = ['title' => 'Failing Article']; $publishException = new PublishException($article, null); @@ -505,7 +361,7 @@ public function test_handle_creates_notification_on_publish_exception(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() ->andThrow($publishException); @@ -528,6 +384,18 @@ public function test_handle_creates_notification_on_publish_exception(): void $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(); diff --git a/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/tests/Unit/Services/Publishing/KeywordFilteringTest.php deleted file mode 100644 index 7a0b80c..0000000 --- a/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ /dev/null @@ -1,281 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($logSaver); - $this->feed = Feed::factory()->create(); - $this->channel1 = PlatformChannel::factory()->create(); - $this->channel2 = PlatformChannel::factory()->create(); - - // Create routes - $this->route1 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - $this->route2 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_route_with_no_keywords_matches_all_articles(): void - { - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Some random article', - 'description' => 'This is about something', - 'full_article' => 'The content talks about various topics', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route with no keywords should match any article'); - } - - public function test_route_with_keywords_matches_article_containing_keyword(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); - } - - public function test_route_with_keywords_does_not_match_article_without_keywords(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'sports', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'football', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Economic news update', - 'description' => 'Markets are doing well', - 'full_article' => 'The economy is showing strong growth this quarter...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertFalse($result, 'Route should not match article without any keywords'); - } - - public function test_inactive_keywords_are_ignored(): void - { - // Add active and inactive keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => false, // Inactive - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, // Active - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedDataWithInactiveKeyword = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - $extractedDataWithActiveKeyword = [ - 'title' => 'Political changes ahead', - 'description' => 'Politics is changing', - 'full_article' => 'The political landscape is shifting...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); - $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); - - $this->assertFalse($result1, 'Route should not match article with inactive keyword'); - $this->assertTrue($result2, 'Route should match article with active keyword'); - } - - public function test_keyword_matching_is_case_insensitive(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'BELGIUM', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'belgium news', - 'description' => 'About Belgium', - 'full_article' => 'News from belgium today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Keyword matching should be case insensitive'); - } - - public function test_keywords_match_in_title_description_and_content(): void - { - $keywordInTitle = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'title-word', - 'is_active' => true, - ]); - - $keywordInDescription = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'keyword' => 'desc-word', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'This contains title-word', - 'description' => 'This has desc-word in it', - 'full_article' => 'The content has no special words', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); - - $this->assertTrue($result1, 'Should match keyword in title'); - $this->assertTrue($result2, 'Should match keyword in description'); - } -}