85 - Update publishing pipeline to use route_articles for per-route publishing

This commit is contained in:
myrmidex 2026-03-18 16:05:31 +01:00
parent f449548123
commit 0c35af4403
5 changed files with 183 additions and 566 deletions

View file

@ -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,
);
}

View file

@ -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<ArticlePublication, $this>
*/
public function articlePublications(): HasMany
{
return $this->hasMany(ArticlePublication::class);
}
/**
* @return BelongsTo<Feed, $this>
*/

View file

@ -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<string, mixed> $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<string, mixed> $extractedData
* @return Collection<int, ArticlePublication>
*
@ -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<string, mixed> $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,
]);

View file

@ -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')
$publishingServiceMock->shouldReceive('publishRouteArticle')
->once()
->andReturn(new Collection(['publication']));
->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();

View file

@ -1,281 +0,0 @@
<?php
namespace Tests\Unit\Services\Publishing;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Services\Log\LogSaver;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class KeywordFilteringTest extends TestCase
{
use RefreshDatabase;
private ArticlePublishingService $service;
private Feed $feed;
private PlatformChannel $channel1;
private PlatformChannel $channel2;
private Route $route1;
private Route $route2;
protected function setUp(): void
{
parent::setUp();
$logSaver = Mockery::mock(LogSaver::class);
$logSaver->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');
}
}