Release v1.3.0 #100

Merged
myrmidex merged 20 commits from release/v1.3.0 into main 2026-03-18 20:30:29 +01:00
8 changed files with 155 additions and 449 deletions
Showing only changes of commit 2b74f24356 - Show all commits

View file

@ -2,16 +2,13 @@
namespace App\Events; namespace App\Events;
use App\Models\Article; use App\Models\RouteArticle;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class ArticleApproved class RouteArticleApproved
{ {
use Dispatchable, SerializesModels; use Dispatchable, SerializesModels;
public function __construct(public Article $article) public function __construct(public RouteArticle $routeArticle) {}
{
//
}
} }

View file

@ -6,7 +6,7 @@
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Events\ActionPerformed; use App\Events\ActionPerformed;
use App\Events\ArticleApproved; use App\Events\RouteArticleApproved;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
@ -23,32 +23,30 @@ public function __construct(
private NotificationService $notificationService, 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 // Skip if already published to this channel
if ($article->articlePublication()->exists()) { if ($article->articlePublications()
->where('platform_channel_id', $routeArticle->platform_channel_id)
->exists()
) {
return; return;
} }
$article->update(['publish_status' => 'publishing']);
try { try {
$extractedData = $this->articleFetcher->fetchArticleData($article); $extractedData = $this->articleFetcher->fetchArticleData($article);
$publications = $this->publishingService->publishToRoutedChannels($article, $extractedData); $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
if ($publications->isNotEmpty()) {
$article->update(['publish_status' => 'published']);
if ($publication) {
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [ ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
} else { } else {
$article->update(['publish_status' => 'error']); ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
]); ]);
@ -57,13 +55,11 @@ public function handle(ArticleApproved $event): void
NotificationTypeEnum::PUBLISH_FAILED, NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING, NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}", "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, $article,
); );
} }
} catch (Exception $e) { } catch (Exception $e) {
$article->update(['publish_status' => 'error']);
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [ ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Events\RouteArticleApproved;
use Database\Factories\RouteArticleFactory; use Database\Factories\RouteArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -88,6 +89,8 @@ public function isRejected(): bool
public function approve(): void public function approve(): void
{ {
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
event(new RouteArticleApproved($this));
} }
public function reject(): void public function reject(): void

View file

@ -36,7 +36,7 @@ public function boot(): void
); );
Event::listen( Event::listen(
\App\Events\ArticleApproved::class, \App\Events\RouteArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class, \App\Listeners\PublishApprovedArticleListener::class,
); );

View file

@ -6,15 +6,12 @@
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\Keyword;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost; use App\Models\PlatformChannelPost;
use App\Models\Route;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Support\Collection;
use RuntimeException; use RuntimeException;
class ArticlePublishingService class ArticlePublishingService
@ -63,63 +60,6 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
return $this->publishToChannel($article, $extractedData, $channel, $account); return $this->publishToChannel($article, $extractedData, $channel, $account);
} }
/**
* @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility.
*
* @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication>
*
* @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<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */

View file

@ -3,8 +3,6 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Events\ActionPerformed; use App\Events\ActionPerformed;
use App\Events\ArticleApproved;
// use App\Events\ArticleReadyToPublish; // Class no longer exists
use App\Events\ExceptionLogged; use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
@ -13,8 +11,6 @@
use App\Jobs\PublishNextArticleJob; use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob; use App\Jobs\SyncChannelPostsJob;
use App\Listeners\LogExceptionToDatabase; 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\Listeners\ValidateArticleListener;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; 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 public function test_exception_occurred_event_is_dispatched(): void
{ {
Event::fake(); Event::fake();
@ -248,13 +217,8 @@ public function test_event_listener_registration_works(): void
$listeners = Event::getListeners(NewArticleFetched::class); $listeners = Event::getListeners(NewArticleFetched::class);
$this->assertNotEmpty($listeners); $this->assertNotEmpty($listeners);
// ArticleApproved event exists but has no listeners after publishing redesign $listeners = Event::getListeners(\App\Events\RouteArticleApproved::class);
// $listeners = Event::getListeners(ArticleApproved::class); $this->assertNotEmpty($listeners);
// $this->assertNotEmpty($listeners);
// ArticleReadyToPublish no longer exists - removed this check
// $listeners = Event::getListeners(ArticleReadyToPublish::class);
// $this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ExceptionOccurred::class); $listeners = Event::getListeners(ExceptionOccurred::class);
$this->assertNotEmpty($listeners); $this->assertNotEmpty($listeners);

View file

@ -4,17 +4,19 @@
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Events\ArticleApproved; use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener; use App\Listeners\PublishApprovedArticleListener;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Notification; use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
@ -22,15 +24,28 @@ class PublishApprovedArticleListenerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_exception_during_publishing_creates_error_notification(): void private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle
{ {
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
/** @var Route $route */
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'title' => $title,
'title' => 'Test Article',
]); ]);
/** @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 = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldReceive('fetchArticleData') $articleFetcherMock->shouldReceive('fetchArticleData')
->once() ->once()
@ -39,13 +54,13 @@ public function test_exception_during_publishing_creates_error_notification(): v
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::ERROR->value, 'severity' => NotificationSeverityEnum::ERROR->value,
'notifiable_type' => $article->getMorphClass(), 'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $article->id, 'notifiable_id' => $routeArticle->article_id,
]); ]);
$notification = Notification::first(); $notification = Notification::first();
@ -53,14 +68,9 @@ public function test_exception_during_publishing_creates_error_notification(): v
$this->assertStringContainsString('Connection refused', $notification->message); $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(); $routeArticle = $this->createApprovedRouteArticle();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'title' => 'Test Article',
]);
$extractedData = ['title' => 'Test Article']; $extractedData = ['title' => 'Test Article'];
@ -70,18 +80,18 @@ public function test_no_publications_created_creates_warning_notification(): voi
->andReturn($extractedData); ->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(new Collection); ->andReturn(null);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
'severity' => NotificationSeverityEnum::WARNING->value, 'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $article->getMorphClass(), 'notifiable_type' => $routeArticle->article->getMorphClass(),
'notifiable_id' => $article->id, 'notifiable_id' => $routeArticle->article_id,
]); ]);
$notification = Notification::first(); $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 public function test_successful_publish_does_not_create_notification(): void
{ {
$feed = Feed::factory()->create(); $routeArticle = $this->createApprovedRouteArticle();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'title' => 'Test Article',
]);
$extractedData = ['title' => 'Test Article']; $extractedData = ['title' => 'Test Article'];
@ -105,16 +110,37 @@ public function test_successful_publish_does_not_create_notification(): void
->andReturn($extractedData); ->andReturn($extractedData);
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishToRoutedChannels') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(new Collection(['publication'])); ->andReturn(ArticlePublication::factory()->make());
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
$listener->handle(new ArticleApproved($article)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseCount('notifications', 0); $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 protected function tearDown(): void
{ {
Mockery::close(); Mockery::close();

View file

@ -10,11 +10,11 @@
use App\Models\PlatformChannelPost; use App\Models\PlatformChannelPost;
use App\Models\PlatformInstance; use App\Models\PlatformInstance;
use App\Models\Route; use App\Models\Route;
use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
@ -44,90 +44,77 @@ protected function tearDown(): void
parent::tearDown(); 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(); $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(); $platformInstance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create(); $account = PlatformAccount::factory()->create();
// Create route /** @var Route $route */
Route::create([ $route = Route::factory()->active()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
'is_active' => true,
'priority' => 50,
]); ]);
// Attach account to channel as active
$channel->platformAccounts()->attach($account->id, [ $channel->platformAccounts()->attach($account->id, [
'is_active' => true, 'is_active' => true,
'priority' => 50, 'priority' => 50,
]); ]);
// Mock publisher via service seam $article = Article::factory()->create(['feed_id' => $feed->id]);
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
/** @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') $publisherDouble->shouldReceive('publishToChannel')
->once() ->once()
->andReturn(['post_view' => ['post' => ['id' => 123]]]); ->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->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$result = $service->publishToRoutedChannels($article, ['title' => 'Hello']);
// Assert $this->assertNotNull($result);
$this->assertCount(1, $result);
$this->assertDatabaseHas('article_publications', [ $this->assertDatabaseHas('article_publications', [
'article_id' => $article->id, 'article_id' => $article->id,
'platform_channel_id' => $channel->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 [$routeArticle] = $this->createRouteArticleWithAccount();
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]);
$platformInstance = PlatformInstance::factory()->create(); $publisherDouble = Mockery::mock(LemmyPublisher::class);
$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->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once() ->once()
->andThrow(new Exception('network error')); ->andThrow(new Exception('network error'));
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$result = $service->publishToRoutedChannels($article, ['title' => 'Hello']);
// Assert $this->assertNull($result);
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0); $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 public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void
{ {
// Arrange [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'validated_at' => now(), // Simulate the URL already being posted to this channel
'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)
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
PlatformEnum::LEMMY, PlatformEnum::LEMMY,
(string) $channel->channel_id, (string) $channel->channel_id,
$channel->name, $channel->name,
'999', '999',
'https://example.com/article-1', $article->url,
'Different Title', 'Different Title',
); );
// Publisher should never be called $publisherDouble = Mockery::mock(LemmyPublisher::class);
$publisherDouble = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldNotReceive('publishToChannel'); $publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
$result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']);
// Assert $this->assertNull($result);
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void
{ {
// Arrange [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();
$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,
]);
// Simulate the same title already posted with a different URL // Simulate the same title already posted with a different URL
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
@ -374,50 +180,25 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
$channel->name, $channel->name,
'888', '888',
'https://example.com/different-url', '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'); $publisherDouble->shouldNotReceive('publishToChannel');
$service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
$result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']);
// Assert $this->assertNull($result);
$this->assertTrue($result->isEmpty());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
public function test_publish_proceeds_when_no_duplicate_exists(): void public function test_publish_proceeds_when_no_duplicate_exists(): void
{ {
// Arrange [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();
$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,
]);
// Existing post in the channel has a completely different URL and title // Existing post in the channel has a completely different URL and title
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
@ -429,19 +210,18 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
'Totally Different Title', 'Totally Different Title',
); );
$publisherDouble = \Mockery::mock(LemmyPublisher::class); $publisherDouble = Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once() ->once()
->andReturn(['post_view' => ['post' => ['id' => 456]]]); ->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->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
// Act $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
$result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']);
// Assert $this->assertNotNull($result);
$this->assertCount(1, $result);
$this->assertDatabaseHas('article_publications', [ $this->assertDatabaseHas('article_publications', [
'article_id' => $article->id, 'article_id' => $article->id,
'post_id' => 456, 'post_id' => 456,