294 lines
11 KiB
PHP
294 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Services\Publishing;
|
|
|
|
use App\Models\Article;
|
|
use App\Models\ArticlePublication;
|
|
use App\Models\Feed;
|
|
use App\Models\PlatformAccount;
|
|
use App\Models\PlatformChannel;
|
|
use App\Models\PlatformChannelPost;
|
|
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\Publishing\ArticlePublishingService;
|
|
use Exception;
|
|
use Illuminate\Contracts\Cache\Lock;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Mockery;
|
|
use Tests\TestCase;
|
|
|
|
class ArticlePublishingServiceTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected ArticlePublishingService $service;
|
|
|
|
protected LogSaver $logSaver;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->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,
|
|
]);
|
|
}
|
|
}
|