85 - Update publishing pipeline to use route_articles for per-route publishing
This commit is contained in:
parent
f449548123
commit
0c35af4403
5 changed files with 183 additions and 566 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue