fedi-feed-router/tests/Feature/DuplicatePublishTest.php

169 lines
6.1 KiB
PHP
Raw Normal View History

<?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_creates_only_one_remote_post(): 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_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());
}
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());
}
}