231 lines
8.2 KiB
PHP
231 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Actions\FetchArticleDataAction;
|
|
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\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(FetchArticleDataAction::class);
|
|
$fetcher->shouldNotReceive('execute');
|
|
|
|
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(FetchArticleDataAction::class);
|
|
$fetcher->shouldNotReceive('execute');
|
|
|
|
$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());
|
|
}
|
|
}
|