198 lines
6.9 KiB
PHP
198 lines
6.9 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace Tests\Feature;
|
||
|
|
|
||
|
|
use App\Actions\PublishRouteArticleAction;
|
||
|
|
use App\Enums\NotificationTypeEnum;
|
||
|
|
use App\Enums\PublishStatusEnum;
|
||
|
|
use App\Events\RouteArticleApproved;
|
||
|
|
use App\Listeners\PublishApprovedArticleListener;
|
||
|
|
use App\Models\Article;
|
||
|
|
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\LemmyApiService;
|
||
|
|
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\Http;
|
||
|
|
use Mockery;
|
||
|
|
use Tests\TestCase;
|
||
|
|
|
||
|
|
class MirrorDuplicateDetectionTest extends TestCase
|
||
|
|
{
|
||
|
|
use RefreshDatabase;
|
||
|
|
|
||
|
|
/** @var array{RouteArticle, PlatformChannel, Article} */
|
||
|
|
private array $fixture;
|
||
|
|
|
||
|
|
protected function setUp(): void
|
||
|
|
{
|
||
|
|
parent::setUp();
|
||
|
|
|
||
|
|
$feed = Feed::factory()->create();
|
||
|
|
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
|
||
|
|
$channel = PlatformChannel::factory()->create([
|
||
|
|
'platform_instance_id' => $instance->id,
|
||
|
|
'channel_id' => 'news',
|
||
|
|
'name' => 'news',
|
||
|
|
]);
|
||
|
|
$account = PlatformAccount::factory()->create(['instance_url' => 'https://lemmy.test']);
|
||
|
|
|
||
|
|
/** @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,
|
||
|
|
'url' => 'https://news.test/already-posted',
|
||
|
|
]);
|
||
|
|
|
||
|
|
/** @var RouteArticle $routeArticle */
|
||
|
|
$routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
|
||
|
|
'article_id' => $article->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
$this->fixture = [$routeArticle, $channel, $article];
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function tearDown(): void
|
||
|
|
{
|
||
|
|
Mockery::close();
|
||
|
|
parent::tearDown();
|
||
|
|
}
|
||
|
|
|
||
|
|
private function service(?LemmyPublisher $publisher = null): ArticlePublishingService
|
||
|
|
{
|
||
|
|
$service = Mockery::mock(ArticlePublishingService::class, [app(LogSaver::class)])->makePartial();
|
||
|
|
$service->shouldAllowMockingProtectedMethods();
|
||
|
|
$service->shouldReceive('makePublisher')->andReturn(
|
||
|
|
$publisher ?? Mockery::mock(LemmyPublisher::class)
|
||
|
|
);
|
||
|
|
|
||
|
|
return $service;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_sync_writes_the_key_the_duplicate_check_reads(): void
|
||
|
|
{
|
||
|
|
[$routeArticle, $channel, $article] = $this->fixture;
|
||
|
|
|
||
|
|
// Populate the mirror through the real sync path rather than seeding a
|
||
|
|
// row by hand, so write and read cannot silently disagree.
|
||
|
|
Http::fake([
|
||
|
|
'*/api/v3/post/list*' => Http::response([
|
||
|
|
'posts' => [[
|
||
|
|
'post' => [
|
||
|
|
'id' => 555,
|
||
|
|
'url' => $article->url,
|
||
|
|
'name' => 'Already Posted',
|
||
|
|
'published' => '2026-08-02T10:00:00Z',
|
||
|
|
],
|
||
|
|
]],
|
||
|
|
]),
|
||
|
|
]);
|
||
|
|
|
||
|
|
(new LemmyApiService('https://lemmy.test'))
|
||
|
|
->syncChannelPosts('token', $channel, 8);
|
||
|
|
|
||
|
|
$this->assertDatabaseCount('platform_channel_posts', 1);
|
||
|
|
|
||
|
|
$publisher = Mockery::mock(LemmyPublisher::class);
|
||
|
|
$publisher->shouldNotReceive('publishToChannel');
|
||
|
|
|
||
|
|
$result = $this->service($publisher)->publishRouteArticle($routeArticle, ['title' => 'Already Posted']);
|
||
|
|
|
||
|
|
$this->assertTrue($result->wasSkipped());
|
||
|
|
$this->assertDatabaseCount('article_publications', 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_skipped_duplicate_is_not_reported_as_a_publish_failure(): void
|
||
|
|
{
|
||
|
|
[$routeArticle, $channel, $article] = $this->fixture;
|
||
|
|
|
||
|
|
PlatformChannelPost::storePost($channel, '555', $article->url, 'Already Posted');
|
||
|
|
|
||
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||
|
|
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Already Posted']);
|
||
|
|
|
||
|
|
$publisher = Mockery::mock(LemmyPublisher::class);
|
||
|
|
$publisher->shouldNotReceive('publishToChannel');
|
||
|
|
|
||
|
|
(new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $this->service($publisher), new NotificationService)))
|
||
|
|
->handle(new RouteArticleApproved($routeArticle));
|
||
|
|
|
||
|
|
$this->assertSame(PublishStatusEnum::SKIPPED, $routeArticle->fresh()->publish_status);
|
||
|
|
$this->assertDatabaseCount('notifications', 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_genuine_failure_is_still_reported(): void
|
||
|
|
{
|
||
|
|
[$routeArticle] = $this->fixture;
|
||
|
|
|
||
|
|
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||
|
|
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Some Title']);
|
||
|
|
|
||
|
|
$publisher = Mockery::mock(LemmyPublisher::class);
|
||
|
|
$publisher->shouldReceive('publishToChannel')->andThrow(new \RuntimeException('Lemmy rejected the post'));
|
||
|
|
|
||
|
|
(new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $this->service($publisher), new NotificationService)))
|
||
|
|
->handle(new RouteArticleApproved($routeArticle));
|
||
|
|
|
||
|
|
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
|
||
|
|
$this->assertDatabaseHas('notifications', [
|
||
|
|
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_mirror_is_keyed_by_the_local_channel(): void
|
||
|
|
{
|
||
|
|
[, $channel, $article] = $this->fixture;
|
||
|
|
|
||
|
|
PlatformChannelPost::storePost(
|
||
|
|
$channel,
|
||
|
|
'555',
|
||
|
|
$article->url,
|
||
|
|
'Already Posted',
|
||
|
|
);
|
||
|
|
|
||
|
|
$this->assertDatabaseHas('platform_channel_posts', [
|
||
|
|
'platform_channel_id' => $channel->id,
|
||
|
|
'post_id' => '555',
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_second_instance_with_the_same_community_name_does_not_collide(): void
|
||
|
|
{
|
||
|
|
[, $channel, $article] = $this->fixture;
|
||
|
|
|
||
|
|
$otherInstance = PlatformInstance::factory()->create(['url' => 'https://other.test']);
|
||
|
|
$otherChannel = PlatformChannel::factory()->create([
|
||
|
|
'platform_instance_id' => $otherInstance->id,
|
||
|
|
'channel_id' => 'news',
|
||
|
|
'name' => 'news',
|
||
|
|
]);
|
||
|
|
|
||
|
|
PlatformChannelPost::storePost($channel, '1', $article->url, 'Same Title');
|
||
|
|
|
||
|
|
// Same community name on a different instance is a different community.
|
||
|
|
$this->assertFalse(
|
||
|
|
PlatformChannelPost::duplicateExists($otherChannel, $article->url, 'Same Title')
|
||
|
|
);
|
||
|
|
|
||
|
|
$this->assertTrue(
|
||
|
|
PlatformChannelPost::duplicateExists($channel, $article->url, 'Same Title')
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|