create(); $instance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->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)->create([ 'article_id' => $article->id, ]); $this->fixture = [$routeArticle, $channel, $article]; } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } /** * Real publishing service with only the Lemmy call faked, counting how many * posts would actually be created remotely. */ private function makeListener(): PublishApprovedArticleListener { $publisher = Mockery::mock(LemmyPublisher::class); $publisher->shouldReceive('publishToChannel') ->andReturnUsing(function () { $this->remoteCalls++; return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]]; }); $service = Mockery::mock( ArticlePublishingService::class, [app(LogSaver::class)] )->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisher); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']); return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService)); } public function test_clicking_approve_twice_dispatches_only_one_event(): void { Event::fake([RouteArticleApproved::class]); [$routeArticle] = $this->fixture; // The double-click: approve() must not dispatch a second time. $routeArticle->approve(); $routeArticle->approve(); Event::assertDispatchedTimes(RouteArticleApproved::class, 1); } public function test_clicking_approve_twice_creates_only_one_remote_post(): void { [$routeArticle, $channel, $article] = $this->fixture; $listener = $this->makeListener(); $routeArticle->approve(); $listener->handle(new RouteArticleApproved($routeArticle->fresh())); $routeArticle->approve(); $listener->handle(new RouteArticleApproved($routeArticle->fresh())); $this->assertSame(1, $this->remoteCalls, 'A second approval must not post to Lemmy again.'); $this->assertSame(1, ArticlePublication::where('article_id', $article->id) ->where('platform_channel_id', $channel->id) ->count()); } public function test_two_queued_listeners_create_only_one_remote_post(): void { [$routeArticle, $channel, $article] = $this->fixture; // Two listeners already in flight. Running them back to back would not // reproduce anything — the second would see the first's publication row // and stop. The real race interleaves: the second listener reaches its // duplicate check while the first is still inside its Lemmy call, before // any row exists. That window is what the lock has to close. $publisher = Mockery::mock(LemmyPublisher::class); $publisher->shouldReceive('publishToChannel') ->andReturnUsing(function () use ($routeArticle) { $this->remoteCalls++; if ($this->remoteCalls === 1) { $this->makeListener()->handle(new RouteArticleApproved($routeArticle->fresh())); } return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]]; }); $service = Mockery::mock( ArticlePublishingService::class, [app(LogSaver::class)] )->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisher); $fetcher = Mockery::mock(ArticleFetcher::class); $fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService)); $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertSame(1, $this->remoteCalls, 'Two listeners must not both post to Lemmy.'); $this->assertSame(1, ArticlePublication::where('article_id', $article->id) ->where('platform_channel_id', $channel->id) ->count()); } /** * Pins the #123 mechanism. Before b2d504f, approve() had no isApproved() * guard, so two calls dispatched two events. Two stale instances of the same * row reproduce that: each still reads PENDING, so the guard cannot fire and * the pre-fix code path runs. */ public function test_without_the_approved_guard_two_approvals_dispatch_two_events(): void { Event::fake([RouteArticleApproved::class]); [$routeArticle] = $this->fixture; $first = RouteArticle::find($routeArticle->id); $second = RouteArticle::find($routeArticle->id); $this->assertTrue($second->isPending(), 'Both instances must start pending for the race to be reproduced.'); $first->approve(); $second->approve(); Event::assertDispatchedTimes(RouteArticleApproved::class, 2); } public function test_two_stale_approvals_still_create_only_one_remote_post(): void { [$routeArticle, $channel, $article] = $this->fixture; $first = RouteArticle::find($routeArticle->id); $second = RouteArticle::find($routeArticle->id); $first->approve(); $second->approve(); $listener = $this->makeListener(); $listener->handle(new RouteArticleApproved($first)); $listener->handle(new RouteArticleApproved($second)); $this->assertSame(1, $this->remoteCalls, 'The lock must hold even when two events get through.'); $this->assertSame(1, ArticlePublication::where('article_id', $article->id) ->where('platform_channel_id', $channel->id) ->count()); } public function test_a_single_approval_publishes_exactly_once(): void { [$routeArticle, $channel, $article] = $this->fixture; $this->makeListener()->handle(new RouteArticleApproved($routeArticle)); $this->assertSame(1, $this->remoteCalls); $this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status); $this->assertSame(1, ArticlePublication::where('article_id', $article->id) ->where('platform_channel_id', $channel->id) ->count()); } }