logSaver = Mockery::mock(LogSaver::class); $this->logSaver->shouldReceive('info')->zeroOrMoreTimes(); $this->logSaver->shouldReceive('warning')->zeroOrMoreTimes(); $this->logSaver->shouldReceive('error')->zeroOrMoreTimes(); $this->logSaver->shouldReceive('debug')->zeroOrMoreTimes(); $this->service = new ArticlePublishingService($this->logSaver); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } /** * @return array{RouteArticle, PlatformChannel, PlatformAccount, Article} */ private function createRouteArticleWithAccount(): array { $feed = Feed::factory()->create(); $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); /** @var Route $route */ $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'platform_channel_id' => $channel->id, ]); $channel->platformAccounts()->attach($account->id, [ 'is_active' => true, 'priority' => 50, ]); $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->assertTrue($result->failed()); $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->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $this->assertTrue($result->succeeded()); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'platform_channel_id' => $channel->id, 'post_id' => 123, 'published_by' => $account->username, ]); } public function test_concurrent_publishes_produce_only_one_remote_post(): void { [$routeArticle, $channel, , $article] = $this->createRouteArticleWithAccount(); $remoteCalls = 0; // A competing listener committed its publication while this one was // between its duplicate check and its own insert. The second attempt // must notice and skip — the unique index cannot retract a remote post. $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->andReturnUsing(function () use (&$remoteCalls, $article, $channel) { $remoteCalls++; ArticlePublication::create([ 'article_id' => $article->id, 'post_id' => 999, 'platform_channel_id' => $channel->id, 'published_by' => 'other-worker', 'published_at' => now(), 'platform' => $channel->platformInstance->platform->value, 'publication_data' => [], ]); return ['post_view' => ['post' => ['id' => 900 + $remoteCalls]]]; }); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $this->assertSame(1, $remoteCalls, 'The remote must be called once, not once per racing listener.'); $this->assertSame(1, ArticlePublication::where('article_id', $article->id) ->where('platform_channel_id', $channel->id) ->count()); } public function test_losing_the_lock_race_skips_without_publishing(): void { [$routeArticle, $channel, , $article] = $this->createRouteArticleWithAccount(); // Another worker holds the lock, so block() gives up and throws. Faked // rather than genuinely contended, so the test does not sit out the wait. $lock = Mockery::mock(Lock::class); $lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException); Cache::shouldReceive('lock') ->with("publish:{$article->id}:{$channel->id}", 180) ->andReturn($lock); $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); // Must decline rather than throw: a LockTimeoutException would reach the // caller's catch block and be recorded as a publish failure. $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $this->assertTrue($result->wasSkipped()); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_route_article_handles_publishing_failure_gracefully(): void { [$routeArticle] = $this->createRouteArticleWithAccount(); $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andThrow(new Exception('network error')); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $this->assertTrue($result->failed()); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void { [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Simulate the URL already being posted to this channel PlatformChannelPost::storePost( $channel, '999', $article->url, 'Different Title', ); $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']); $this->assertTrue($result->wasSkipped()); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void { [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Simulate the same title already posted with a different URL PlatformChannelPost::storePost( $channel, '888', 'https://example.com/different-url', 'Breaking News', ); $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']); $this->assertTrue($result->wasSkipped()); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_proceeds_when_no_duplicate_exists(): void { [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Existing post in the channel has a completely different URL and title PlatformChannelPost::storePost( $channel, '777', 'https://example.com/other-article', 'Totally Different Title', ); $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 456]]]); $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']); $this->assertTrue($result->succeeded()); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'post_id' => 456, ]); } }