fedi-feed-router/tests/Feature/DuplicatePublishTest.php
myrmidex d0d81e524e
Some checks failed
CI / ci (pull_request) Failing after 26m6s
CI / ci (push) Successful in 21m12s
114 - Select channel communities from the instance instead of typing a slug
2026-08-06 00:20:49 +02:00

231 lines
8.3 KiB
PHP

<?php
namespace Tests\Feature;
use App\Actions\PublishRouteArticleAction;
use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Mockery;
use Tests\TestCase;
/**
* Reproduces #123: one article reaching a Lemmy community twice.
*
* These drive the real listener and the real publishing service, faking only
* the Lemmy boundary, so the guard and the lock are actually exercised.
*/
class DuplicatePublishTest extends TestCase
{
use RefreshDatabase;
/** @var array{RouteArticle, PlatformChannel, Article} */
private array $fixture;
private int $remoteCalls = 0;
protected function setUp(): void
{
parent::setUp();
$feed = Feed::factory()->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());
}
}