From 2b74f24356229f1844b70226138e797dcd35b81b Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:00:56 +0100 Subject: [PATCH] 85 - Replace ArticleApproved with RouteArticleApproved event and update publishing listener --- ...eApproved.php => RouteArticleApproved.php} | 9 +- .../PublishApprovedArticleListener.php | 30 +- app/Models/RouteArticle.php | 3 + app/Providers/AppServiceProvider.php | 2 +- .../Publishing/ArticlePublishingService.php | 60 --- tests/Feature/JobsAndEventsTest.php | 40 +- .../PublishApprovedArticleListenerTest.php | 84 ++-- .../ArticlePublishingServiceTest.php | 376 ++++-------------- 8 files changed, 155 insertions(+), 449 deletions(-) rename app/Events/{ArticleApproved.php => RouteArticleApproved.php} (56%) diff --git a/app/Events/ArticleApproved.php b/app/Events/RouteArticleApproved.php similarity index 56% rename from app/Events/ArticleApproved.php rename to app/Events/RouteArticleApproved.php index 1e9ec9b..7f1e1f6 100644 --- a/app/Events/ArticleApproved.php +++ b/app/Events/RouteArticleApproved.php @@ -2,16 +2,13 @@ namespace App\Events; -use App\Models\Article; +use App\Models\RouteArticle; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ArticleApproved +class RouteArticleApproved { use Dispatchable, SerializesModels; - public function __construct(public Article $article) - { - // - } + public function __construct(public RouteArticle $routeArticle) {} } diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index 0bb3243..ccd7764 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -6,7 +6,7 @@ use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; -use App\Events\ArticleApproved; +use App\Events\RouteArticleApproved; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; @@ -23,32 +23,30 @@ public function __construct( private NotificationService $notificationService, ) {} - public function handle(ArticleApproved $event): void + public function handle(RouteArticleApproved $event): void { - $article = $event->article->fresh(); + $routeArticle = $event->routeArticle; + $article = $routeArticle->article; - // Skip if already published - if ($article->articlePublication()->exists()) { + // Skip if already published to this channel + if ($article->articlePublications() + ->where('platform_channel_id', $routeArticle->platform_channel_id) + ->exists() + ) { return; } - $article->update(['publish_status' => 'publishing']); - try { $extractedData = $this->articleFetcher->fetchArticleData($article); - $publications = $this->publishingService->publishToRoutedChannels($article, $extractedData); - - if ($publications->isNotEmpty()) { - $article->update(['publish_status' => 'published']); + $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); + if ($publication) { ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { - $article->update(['publish_status' => 'error']); - - ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [ + ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, ]); @@ -57,13 +55,11 @@ public function handle(ArticleApproved $event): void 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, ); } } catch (Exception $e) { - $article->update(['publish_status' => 'error']); - ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 605f153..8b7c4ac 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApprovalStatusEnum; +use App\Events\RouteArticleApproved; use Database\Factories\RouteArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -88,6 +89,8 @@ public function isRejected(): bool public function approve(): void { $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); + + event(new RouteArticleApproved($this)); } public function reject(): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2a25ea8..c9b6f5d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -36,7 +36,7 @@ public function boot(): void ); Event::listen( - \App\Events\ArticleApproved::class, + \App\Events\RouteArticleApproved::class, \App\Listeners\PublishApprovedArticleListener::class, ); diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index bac0db9..73079e1 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -6,15 +6,12 @@ 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; -use Illuminate\Support\Collection; use RuntimeException; class ArticlePublishingService @@ -63,63 +60,6 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted return $this->publishToChannel($article, $extractedData, $channel, $account); } - /** - * @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility. - * - * @param array $extractedData - * @return Collection - * - * @throws PublishException - */ - public function publishToRoutedChannels(Article $article, array $extractedData): Collection - { - $feed = $article->feed; - - $activeRoutes = Route::where('feed_id', $feed->id) - ->where('is_active', true) - ->get(); - - $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 = PlatformChannel::with(['platformInstance', 'activePlatformAccounts'])->find($route->platform_channel_id); - $account = $channel?->activePlatformAccounts()->first(); - - if (! $account) { - $this->logSaver->warning('No active account for channel', $channel, [ - 'article_id' => $article->id, - 'route_priority' => $route->priority, - ]); - - return null; - } - - return $this->publishToChannel($article, $extractedData, $channel, $account); - })->filter(); - } - /** * @param array $extractedData */ diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index 9c2e3ef..e1337ac 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -3,8 +3,6 @@ namespace Tests\Feature; use App\Events\ActionPerformed; -use App\Events\ArticleApproved; -// use App\Events\ArticleReadyToPublish; // Class no longer exists use App\Events\ExceptionLogged; use App\Events\ExceptionOccurred; use App\Events\NewArticleFetched; @@ -13,8 +11,6 @@ use App\Jobs\PublishNextArticleJob; use App\Jobs\SyncChannelPostsJob; use App\Listeners\LogExceptionToDatabase; -// use App\Listeners\PublishApprovedArticle; // Class no longer exists -// use App\Listeners\PublishArticle; // Class no longer exists use App\Listeners\ValidateArticleListener; use App\Models\Article; use App\Models\Feed; @@ -116,33 +112,6 @@ public function test_new_article_fetched_event_is_dispatched(): void }); } - public function test_article_approved_event_is_dispatched(): void - { - Event::fake(); - - $article = Article::factory()->create(); - - event(new ArticleApproved($article)); - - Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) { - return $event->article->id === $article->id; - }); - } - - // Test removed - ArticleReadyToPublish class no longer exists - // public function test_article_ready_to_publish_event_is_dispatched(): void - // { - // Event::fake(); - - // $article = Article::factory()->create(); - - // event(new ArticleReadyToPublish($article)); - - // Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) { - // return $event->article->id === $article->id; - // }); - // } - public function test_exception_occurred_event_is_dispatched(): void { Event::fake(); @@ -248,13 +217,8 @@ public function test_event_listener_registration_works(): void $listeners = Event::getListeners(NewArticleFetched::class); $this->assertNotEmpty($listeners); - // ArticleApproved event exists but has no listeners after publishing redesign - // $listeners = Event::getListeners(ArticleApproved::class); - // $this->assertNotEmpty($listeners); - - // ArticleReadyToPublish no longer exists - removed this check - // $listeners = Event::getListeners(ArticleReadyToPublish::class); - // $this->assertNotEmpty($listeners); + $listeners = Event::getListeners(\App\Events\RouteArticleApproved::class); + $this->assertNotEmpty($listeners); $listeners = Event::getListeners(ExceptionOccurred::class); $this->assertNotEmpty($listeners); diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php index b23ac11..fa0bb85 100644 --- a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -4,17 +4,19 @@ use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; -use App\Events\ArticleApproved; +use App\Events\RouteArticleApproved; use App\Listeners\PublishApprovedArticleListener; use App\Models\Article; +use App\Models\ArticlePublication; use App\Models\Feed; use App\Models\Notification; +use App\Models\Route; +use App\Models\RouteArticle; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Exception; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -22,15 +24,28 @@ class PublishApprovedArticleListenerTest extends TestCase { use RefreshDatabase; - public function test_exception_during_publishing_creates_error_notification(): void + private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle { $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); $article = Article::factory()->create([ 'feed_id' => $feed->id, - - 'title' => 'Test Article', + 'title' => $title, ]); + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + return $routeArticle; + } + + public function test_exception_during_publishing_creates_error_notification(): void + { + $routeArticle = $this->createApprovedRouteArticle(); + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() @@ -39,13 +54,13 @@ public function test_exception_during_publishing_creates_error_notification(): v $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::ERROR->value, - 'notifiable_type' => $article->getMorphClass(), - 'notifiable_id' => $article->id, + 'notifiable_type' => $routeArticle->article->getMorphClass(), + 'notifiable_id' => $routeArticle->article_id, ]); $notification = Notification::first(); @@ -53,14 +68,9 @@ public function test_exception_during_publishing_creates_error_notification(): v $this->assertStringContainsString('Connection refused', $notification->message); } - public function test_no_publications_created_creates_warning_notification(): void + public function test_no_publication_created_creates_warning_notification(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'title' => 'Test Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(); $extractedData = ['title' => 'Test Article']; @@ -70,18 +80,18 @@ public function test_no_publications_created_creates_warning_notification(): voi ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection); + ->andReturn(null); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $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(); @@ -90,12 +100,7 @@ public function test_no_publications_created_creates_warning_notification(): voi public function test_successful_publish_does_not_create_notification(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'title' => 'Test Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(); $extractedData = ['title' => 'Test Article']; @@ -105,16 +110,37 @@ public function test_successful_publish_does_not_create_notification(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertDatabaseCount('notifications', 0); } + public function test_skips_already_published_to_channel(): void + { + $routeArticle = $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->create([ + 'article_id' => $routeArticle->article_id, + 'platform_channel_id' => $routeArticle->platform_channel_id, + ]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new RouteArticleApproved($routeArticle)); + + $this->assertTrue(true); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 40e2c8d..61a8d17 100644 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -10,11 +10,11 @@ use App\Models\PlatformChannelPost; use App\Models\PlatformInstance; use App\Models\Route; +use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use App\Services\Publishing\ArticlePublishingService; use Exception; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -44,90 +44,77 @@ protected function tearDown(): void parent::tearDown(); } - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void + /** + * @return array{RouteArticle, PlatformChannel, PlatformAccount, Article} + */ + private function createRouteArticleWithAccount(): array { $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - ]); - $extractedData = ['title' => 'Test Title']; - - $result = $this->service->publishToRoutedChannels($article, $extractedData); - - $this->assertInstanceOf(EloquentCollection::class, $result); - $this->assertTrue($result->isEmpty()); - } - - public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void - { - // Arrange: valid article - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - ]); - - // Create a route with a channel but no active accounts - $channel = PlatformChannel::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Don't create any platform accounts for the channel - - // Act - $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_successfully_publishes_to_channel(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); - // Create route - Route::create([ + /** @var Route $route */ + $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, ]); - // Attach account to channel as active $channel->platformAccounts()->attach($account->id, [ 'is_active' => true, 'priority' => 50, ]); - // Mock publisher via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + return [$routeArticle, $channel, $account, $article]; + } + + public function test_publish_route_article_returns_null_when_no_active_account(): void + { + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + /** @var Route $route */ + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + $result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']); + + $this->assertNull($result); + $this->assertDatabaseCount('article_publications', 0); + } + + public function test_publish_route_article_successfully_publishes(): void + { + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); + + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'platform_channel_id' => $channel->id, @@ -136,236 +123,55 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe ]); } - public function test_publish_to_routed_channels_handles_publishing_failure_gracefully(): void + public function test_publish_route_article_handles_publishing_failure_gracefully(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); + [$routeArticle] = $this->createRouteArticleWithAccount(); - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Publisher throws an exception via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } - public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(2, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 100]); - $this->assertDatabaseHas('article_publications', ['post_id' => 200]); - } - - public function test_publish_to_routed_channels_filters_out_failed_publications(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 300]); - $this->assertDatabaseCount('article_publications', 1); - } - public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); - 'validated_at' => now(), - 'url' => 'https://example.com/article-1', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Simulate the URL already being posted to this channel (synced from Lemmy) + // Simulate the URL already being posted to this channel PlatformChannelPost::storePost( PlatformEnum::LEMMY, (string) $channel->channel_id, $channel->name, '999', - 'https://example.com/article-1', + $article->url, 'Different Title', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - 'url' => 'https://example.com/article-new-url', - 'title' => 'Breaking News: Something Happened', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Simulate the same title already posted with a different URL PlatformChannelPost::storePost( @@ -374,50 +180,25 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe $channel->name, '888', 'https://example.com/different-url', - 'Breaking News: Something Happened', + 'Breaking News', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_proceeds_when_no_duplicate_exists(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - 'url' => 'https://example.com/unique-article', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Existing post in the channel has a completely different URL and title PlatformChannelPost::storePost( @@ -429,19 +210,18 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void 'Totally Different Title', ); - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 456]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'post_id' => 456,