123 - Key duplicate mirror by local channel and distinguish skipped publishes

This commit is contained in:
myrmidex 2026-08-02 17:16:30 +02:00
parent b2d504f503
commit b527813721
19 changed files with 711 additions and 207 deletions

View file

@ -0,0 +1,107 @@
<?php
namespace App\Actions;
use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Exceptions\PublishException;
use App\Models\RouteArticle;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome;
use Exception;
class PublishRouteArticleAction
{
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService,
private NotificationService $notificationService,
) {}
/**
* @throws PublishException
*/
public function execute(RouteArticle $routeArticle): PublishOutcome
{
$article = $routeArticle->article;
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$outcome = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
} catch (Exception $e) {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
throw $e;
}
match (true) {
$outcome->succeeded() => $this->recordPublished($routeArticle),
$outcome->wasSkipped() => $this->recordSkipped($routeArticle, $outcome),
default => $this->recordFailed($routeArticle, $outcome),
};
return $outcome;
}
private function recordPublished(RouteArticle $routeArticle): void
{
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
'article_id' => $routeArticle->article->id,
'title' => $routeArticle->article->title,
]);
}
private function recordSkipped(RouteArticle $routeArticle, PublishOutcome $outcome): void
{
$routeArticle->update(['publish_status' => PublishStatusEnum::SKIPPED]);
ActionPerformed::dispatch('Skipped publishing article', LogLevelEnum::INFO, [
'article_id' => $routeArticle->article->id,
'title' => $routeArticle->article->title,
'reason' => $outcome->reason,
]);
}
private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcome): void
{
$article = $routeArticle->article;
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
'reason' => $outcome->reason,
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
$outcome->reason ?? 'No publication was created for this article.',
$article,
);
}
}

View file

@ -7,5 +7,6 @@ enum PublishStatusEnum: string
case UNPUBLISHED = 'unpublished'; case UNPUBLISHED = 'unpublished';
case PUBLISHING = 'publishing'; case PUBLISHING = 'publishing';
case PUBLISHED = 'published'; case PUBLISHED = 'published';
case SKIPPED = 'skipped';
case ERROR = 'error'; case ERROR = 'error';
} }

View file

@ -2,19 +2,14 @@
namespace App\Jobs; namespace App\Jobs;
use App\Actions\PublishRouteArticleAction;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed; use App\Events\ActionPerformed;
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
@ -38,7 +33,7 @@ public function __construct()
* *
* @throws PublishException * @throws PublishException
*/ */
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void public function handle(PublishRouteArticleAction $publishRouteArticle): void
{ {
$interval = Setting::getArticlePublishingInterval(); $interval = Setting::getArticlePublishingInterval();
@ -72,52 +67,6 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id, 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
]); ]);
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); $publishRouteArticle->execute($routeArticle);
try {
$extractedData = $articleFetcher->fetchArticleData($article);
$publication = $publishingService->publishRouteArticle($routeArticle, $extractedData);
if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
}
} catch (PublishException $e) {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
$notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
throw $e;
}
} }
} }

View file

@ -70,7 +70,7 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
$communityId = $api->resolveCommunityId($this->channel->channel_id, $token); $communityId = $api->resolveCommunityId($this->channel->channel_id, $token);
$api->syncChannelPosts($token, $communityId, $this->channel->name); $api->syncChannelPosts($token, $this->channel, $communityId);
$logSaver->info('Channel posts synced successfully', $this->channel); $logSaver->info('Channel posts synced successfully', $this->channel);
} catch (Exception $e) { } catch (Exception $e) {

View file

@ -2,15 +2,8 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\LogLevelEnum; use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Events\RouteArticleApproved; use App\Events\RouteArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Exception; use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -19,9 +12,7 @@ class PublishApprovedArticleListener implements ShouldQueue
public string $queue = 'publishing'; public string $queue = 'publishing';
public function __construct( public function __construct(
private ArticleFetcher $articleFetcher, private PublishRouteArticleAction $publishRouteArticle,
private ArticlePublishingService $publishingService,
private NotificationService $notificationService,
) {} ) {}
public function handle(RouteArticleApproved $event): void public function handle(RouteArticleApproved $event): void
@ -29,7 +20,6 @@ public function handle(RouteArticleApproved $event): void
$routeArticle = $event->routeArticle; $routeArticle = $event->routeArticle;
$article = $routeArticle->article; $article = $routeArticle->article;
// Skip if already published to this channel
if ($article->articlePublications() if ($article->articlePublications()
->where('platform_channel_id', $routeArticle->platform_channel_id) ->where('platform_channel_id', $routeArticle->platform_channel_id)
->exists() ->exists()
@ -37,50 +27,10 @@ public function handle(RouteArticleApproved $event): void
return; return;
} }
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try { try {
$extractedData = $this->articleFetcher->fetchArticleData($article); $this->publishRouteArticle->execute($routeArticle);
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); } catch (Exception) {
// The action has already recorded the failure and notified.
if ($publication) {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
'article_id' => $article->id,
'title' => $article->title,
]);
} else {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
'article_id' => $article->id,
'title' => $article->title,
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::WARNING,
"Publish failed: {$article->title}",
'No publication was created for this article. Check channel routing configuration.',
$article,
);
}
} catch (Exception $e) {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
'article_id' => $article->id,
'error' => $e->getMessage(),
]);
$this->notificationService->send(
NotificationTypeEnum::PUBLISH_FAILED,
NotificationSeverityEnum::ERROR,
"Publish failed: {$article->title}",
$e->getMessage(),
$article,
);
} }
} }
} }

View file

@ -2,13 +2,12 @@
namespace App\Models; namespace App\Models;
use App\Enums\PlatformEnum;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @method static where(string $string, PlatformEnum $platform)
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1) * @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
*/ */
class PlatformChannelPost extends Model class PlatformChannelPost extends Model
@ -17,9 +16,7 @@ class PlatformChannelPost extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'platform', 'platform_channel_id',
'channel_id',
'channel_name',
'post_id', 'post_id',
'url', 'url',
'title', 'title',
@ -33,26 +30,24 @@ protected function casts(): array
{ {
return [ return [
'posted_at' => 'datetime', 'posted_at' => 'datetime',
'platform' => PlatformEnum::class,
]; ];
} }
public static function urlExists(PlatformEnum $platform, string $channelId, string $url): bool /**
* @return BelongsTo<PlatformChannel, $this>
*/
public function platformChannel(): BelongsTo
{ {
return self::where('platform', $platform) return $this->belongsTo(PlatformChannel::class);
->where('channel_id', $channelId)
->where('url', $url)
->exists();
} }
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool public static function duplicateExists(PlatformChannel $channel, ?string $url, ?string $title): bool
{ {
if (! $url && ! $title) { if (! $url && ! $title) {
return false; return false;
} }
return self::where('platform', $platform) return self::where('platform_channel_id', $channel->id)
->where('channel_id', $channelId)
->where(function ($query) use ($url, $title) { ->where(function ($query) use ($url, $title) {
if ($url) { if ($url) {
$query->orWhere('url', $url); $query->orWhere('url', $url);
@ -64,16 +59,14 @@ public static function duplicateExists(PlatformEnum $platform, string $channelId
->exists(); ->exists();
} }
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self public static function storePost(PlatformChannel $channel, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
{ {
return self::updateOrCreate( return self::updateOrCreate(
[ [
'platform' => $platform, 'platform_channel_id' => $channel->id,
'channel_id' => $channelId,
'post_id' => $postId, 'post_id' => $postId,
], ],
[ [
'channel_name' => $channelName,
'url' => $url, 'url' => $url,
'title' => $title, 'title' => $title,
'posted_at' => $postedAt ?? now(), 'posted_at' => $postedAt ?? now(),

View file

@ -2,7 +2,7 @@
namespace App\Modules\Lemmy\Services; namespace App\Modules\Lemmy\Services;
use App\Enums\PlatformEnum; use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost; use App\Models\PlatformChannelPost;
use App\Modules\Lemmy\LemmyRequest; use App\Modules\Lemmy\LemmyRequest;
use Exception; use Exception;
@ -117,12 +117,12 @@ public function getCommunityId(string $communityName, string $token): int
} }
} }
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void public function syncChannelPosts(string $token, PlatformChannel $channel, int $communityId): void
{ {
try { try {
$request = new LemmyRequest($this->instance, $token); $request = new LemmyRequest($this->instance, $token);
$response = $request->get('post/list', [ $response = $request->get('post/list', [
'community_id' => $platformChannelId, 'community_id' => $communityId,
'limit' => 50, 'limit' => 50,
'sort' => 'New', 'sort' => 'New',
]); ]);
@ -130,7 +130,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
if (! $response->successful()) { if (! $response->successful()) {
logger()->warning('Failed to sync channel posts', [ logger()->warning('Failed to sync channel posts', [
'status' => $response->status(), 'status' => $response->status(),
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $channel->id,
]); ]);
return; return;
@ -143,9 +143,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
$post = $postData['post']; $post = $postData['post'];
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
PlatformEnum::LEMMY, $channel,
(string) $platformChannelId,
$communityName,
(string) $post['id'], (string) $post['id'],
$post['url'] ?? null, $post['url'] ?? null,
$post['name'] ?? null, $post['name'] ?? null,
@ -154,14 +152,14 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
} }
logger()->info('Synced channel posts', [ logger()->info('Synced channel posts', [
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $channel->id,
'posts_count' => count($posts), 'posts_count' => count($posts),
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
logger()->error('Exception while syncing channel posts', [ logger()->error('Exception while syncing channel posts', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $channel->id,
]); ]);
} }
} }

View file

@ -39,7 +39,7 @@ protected function makePublisher(mixed $account): LemmyPublisher
* *
* @throws PublishException * @throws PublishException
*/ */
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): PublishOutcome
{ {
$article = $routeArticle->article; $article = $routeArticle->article;
$channel = $routeArticle->platformChannel; $channel = $routeArticle->platformChannel;
@ -60,7 +60,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
'route_article_id' => $routeArticle->id, 'route_article_id' => $routeArticle->id,
]); ]);
return null; return PublishOutcome::failure('No active account for channel');
} }
return $this->publishToChannel($article, $extractedData, $channel, $account); return $this->publishToChannel($article, $extractedData, $channel, $account);
@ -69,7 +69,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
{ {
$lock = Cache::lock("publish:{$article->id}:{$channel->id}", self::LOCK_TTL_SECONDS); $lock = Cache::lock("publish:{$article->id}:{$channel->id}", self::LOCK_TTL_SECONDS);
@ -84,7 +84,7 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'article_id' => $article->id, 'article_id' => $article->id,
]); ]);
return null; return PublishOutcome::skipped('Already published to this channel');
} }
return $this->doPublishToChannel($article, $extractedData, $channel, $account); return $this->doPublishToChannel($article, $extractedData, $channel, $account);
@ -94,31 +94,26 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'article_id' => $article->id, 'article_id' => $article->id,
]); ]);
return null; return PublishOutcome::skipped('Another worker is publishing this article');
} }
} }
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */
private function doPublishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication private function doPublishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
{ {
try { try {
// Check if this URL or title was already posted to this channel // Check if this URL or title was already posted to this channel
$title = $extractedData['title'] ?? $article->title; $title = $extractedData['title'] ?? $article->title;
if (PlatformChannelPost::duplicateExists( if (PlatformChannelPost::duplicateExists($channel, $article->url, $title)) {
$channel->platformInstance->platform,
(string) $channel->channel_id,
$article->url,
$title
)) {
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [ $this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url, 'url' => $article->url,
'title' => $title, 'title' => $title,
]); ]);
return null; return PublishOutcome::skipped('URL or title already posted to this channel');
} }
$publisher = $this->makePublisher($account); $publisher = $this->makePublisher($account);
@ -138,14 +133,14 @@ private function doPublishToChannel(Article $article, array $extractedData, Plat
'article_id' => $article->id, 'article_id' => $article->id,
]); ]);
return $publication; return PublishOutcome::published($publication);
} catch (Exception $e) { } catch (Exception $e) {
$this->logSaver->warning('Failed to publish to channel', $channel, [ $this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
return null; return PublishOutcome::failure($e->getMessage());
} }
} }
} }

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services\Publishing;
use App\Models\ArticlePublication;
/**
* Publishing has three outcomes, not two: it can succeed, be deliberately
* skipped, or fail. Returning a bare null for the last two made every skip
* surface as a publish failure (#123).
*/
class PublishOutcome
{
private function __construct(
public readonly ?ArticlePublication $publication,
public readonly bool $skipped,
public readonly ?string $reason = null,
) {}
public static function published(ArticlePublication $publication): self
{
return new self($publication, false);
}
public static function skipped(string $reason): self
{
return new self(null, true, $reason);
}
public static function failure(string $reason): self
{
return new self(null, false, $reason);
}
public function succeeded(): bool
{
return $this->publication !== null;
}
public function wasSkipped(): bool
{
return $this->skipped;
}
public function failed(): bool
{
return ! $this->succeeded() && ! $this->skipped;
}
}

View file

@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The mirror was written with Lemmy's numeric community id and read with the
* community slug, so duplicate detection never matched (#123). Keying on the
* local platform_channels.id removes the ambiguity and the per-instance
* collision that both remote identifiers share.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('platform_channel_posts', function (Blueprint $table) {
$table->dropUnique('channel_post_unique');
$table->dropIndex(['platform', 'channel_id', 'url']);
$table->dropIndex(['platform', 'channel_id', 'title']);
$table->unsignedBigInteger('platform_channel_id')->nullable()->after('id');
});
// A name shared by two instances is ambiguous; those rows stay unmapped.
DB::table('platform_channel_posts')->orderBy('id')->chunkById(200, function ($rows) {
foreach ($rows as $row) {
$matches = DB::table('platform_channels')
->where('name', $row->channel_name)
->orWhere('channel_id', $row->channel_name)
->pluck('id');
if ($matches->count() !== 1) {
continue;
}
DB::table('platform_channel_posts')
->where('id', $row->id)
->update(['platform_channel_id' => $matches->first()]);
}
});
// Unmappable rows are discarded rather than guessed: the mirror is a
// cache SyncChannelPostsJob rebuilds every ten minutes.
DB::table('platform_channel_posts')->whereNull('platform_channel_id')->delete();
Schema::table('platform_channel_posts', function (Blueprint $table) {
$table->unsignedBigInteger('platform_channel_id')->nullable(false)->change();
$table->dropColumn(['platform', 'channel_id', 'channel_name']);
});
Schema::table('platform_channel_posts', function (Blueprint $table) {
$table->foreign('platform_channel_id')->references('id')->on('platform_channels')->onDelete('cascade');
$table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique');
$table->index(['platform_channel_id', 'url']);
$table->index(['platform_channel_id', 'title']);
});
}
public function down(): void
{
Schema::table('platform_channel_posts', function (Blueprint $table) {
$table->dropForeign(['platform_channel_id']);
$table->dropUnique('channel_post_unique');
$table->dropIndex(['platform_channel_id', 'url']);
$table->dropIndex(['platform_channel_id', 'title']);
$table->string('platform')->default('lemmy');
$table->string('channel_id')->default('');
$table->string('channel_name')->nullable();
});
DB::table('platform_channel_posts')->update([
'channel_id' => DB::raw('platform_channel_id'),
]);
Schema::table('platform_channel_posts', function (Blueprint $table) {
$table->dropColumn('platform_channel_id');
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
$table->index(['platform', 'channel_id', 'url']);
$table->index(['platform', 'channel_id', 'title']);
});
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Publishing can be deliberately skipped (already published, duplicate in the
* channel, another worker holds the lock). Those were recorded as errors (#123).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('route_articles', function (Blueprint $table) {
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'skipped', 'error'])
->default('unpublished')
->change();
});
}
public function down(): void
{
DB::table('route_articles')
->where('publish_status', 'skipped')
->update(['publish_status' => 'unpublished']);
Schema::table('route_articles', function (Blueprint $table) {
$table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])
->default('unpublished')
->change();
});
}
};

View file

@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Actions\PublishRouteArticleAction;
use App\Enums\PublishStatusEnum; use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved; use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener; use App\Listeners\PublishApprovedArticleListener;
@ -95,7 +96,7 @@ private function makeListener(): PublishApprovedArticleListener
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(ArticleFetcher::class);
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']); $fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
return new PublishApprovedArticleListener($fetcher, $service, new NotificationService); return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
} }
public function test_clicking_approve_twice_creates_only_one_remote_post(): void public function test_clicking_approve_twice_creates_only_one_remote_post(): void
@ -142,7 +143,7 @@ public function test_two_queued_listeners_create_only_one_remote_post(): void
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(ArticleFetcher::class);
$fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']); $fetcher->shouldReceive('fetchArticleData')->andReturn(['title' => 'Test Article']);
$listener = new PublishApprovedArticleListener($fetcher, $service, new NotificationService); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertSame(1, $this->remoteCalls, 'Two listeners must not both post to Lemmy.'); $this->assertSame(1, $this->remoteCalls, 'Two listeners must not both post to Lemmy.');

View file

@ -0,0 +1,130 @@
<?php
namespace Tests\Feature;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\PlatformInstance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class KeyPlatformChannelPostsMigrationTest extends TestCase
{
use RefreshDatabase;
private function runMigration(): void
{
$migration = require database_path('migrations/2024_01_01_000013_key_platform_channel_posts_by_local_channel.php');
$migration->up();
}
private function restoreLegacyTable(): void
{
Schema::dropIfExists('platform_channel_posts');
Schema::create('platform_channel_posts', function (Blueprint $table) {
$table->id();
$table->string('platform');
$table->string('channel_id');
$table->string('channel_name')->nullable();
$table->string('post_id');
$table->string('title')->nullable();
$table->string('url')->nullable();
$table->timestamp('posted_at')->nullable();
$table->timestamps();
$table->unique(['platform', 'channel_id', 'post_id'], 'channel_post_unique');
$table->index(['platform', 'channel_id', 'url']);
$table->index(['platform', 'channel_id', 'title']);
});
}
private function seedLegacyRow(string $channelId, ?string $channelName, string $postId): void
{
DB::table('platform_channel_posts')->insert([
'platform' => 'lemmy',
'channel_id' => $channelId,
'channel_name' => $channelName,
'post_id' => $postId,
'url' => "https://news.test/{$postId}",
'title' => "Post {$postId}",
'posted_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
public function test_maps_rows_to_the_local_channel_by_name(): void
{
$this->restoreLegacyTable();
$channel = PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
$this->seedLegacyRow('217', 'newsbottest', '1');
$this->seedLegacyRow('217', 'newsbottest', '2');
$this->runMigration();
$this->assertSame(2, DB::table('platform_channel_posts')
->where('platform_channel_id', $channel->id)->count());
}
public function test_drops_rows_that_match_no_channel(): void
{
$this->restoreLegacyTable();
PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
$this->seedLegacyRow('999', 'a-community-that-no-longer-exists', '1');
$this->runMigration();
$this->assertSame(0, DB::table('platform_channel_posts')->count());
}
public function test_drops_rows_whose_channel_name_is_ambiguous_across_instances(): void
{
$this->restoreLegacyTable();
$first = PlatformInstance::factory()->create(['url' => 'https://one.test']);
$second = PlatformInstance::factory()->create(['url' => 'https://two.test']);
PlatformChannel::factory()->create([
'platform_instance_id' => $first->id,
'name' => 'news',
'channel_id' => 'news',
]);
PlatformChannel::factory()->create([
'platform_instance_id' => $second->id,
'name' => 'news',
'channel_id' => 'news-two',
]);
$this->seedLegacyRow('8', 'news', '1');
$this->runMigration();
$this->assertSame(0, DB::table('platform_channel_posts')->count());
}
public function test_leaves_a_schema_the_new_code_can_use(): void
{
$this->restoreLegacyTable();
$channel = PlatformChannel::factory()->create(['name' => 'newsbottest', 'channel_id' => 'newsbottest']);
$this->seedLegacyRow('217', 'newsbottest', '1');
$this->runMigration();
$this->assertTrue(Schema::hasColumn('platform_channel_posts', 'platform_channel_id'));
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'channel_id'));
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'channel_name'));
$this->assertFalse(Schema::hasColumn('platform_channel_posts', 'platform'));
$this->assertTrue(
PlatformChannelPost::duplicateExists($channel, 'https://news.test/1', 'Post 1')
);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature\Listeners; namespace Tests\Feature\Listeners;
use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Events\RouteArticleApproved; use App\Events\RouteArticleApproved;
@ -15,6 +16,7 @@
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
@ -53,7 +55,7 @@ public function test_exception_during_publishing_creates_error_notification(): v
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
@ -82,9 +84,9 @@ public function test_no_publication_created_creates_warning_notification(): void
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(null); ->andReturn(PublishOutcome::failure('No publication created'));
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
@ -112,9 +114,9 @@ public function test_successful_publish_does_not_create_notification(): void
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertDatabaseCount('notifications', 0); $this->assertDatabaseCount('notifications', 0);
@ -135,7 +137,7 @@ public function test_skips_already_published_to_channel(): void
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
$listener->handle(new RouteArticleApproved($routeArticle)); $listener->handle(new RouteArticleApproved($routeArticle));
$this->assertTrue(true); $this->assertTrue(true);
@ -146,4 +148,12 @@ protected function tearDown(): void
Mockery::close(); Mockery::close();
parent::tearDown(); parent::tearDown();
} }
private function makePublication(): ArticlePublication
{
/** @var ArticlePublication $publication */
$publication = ArticlePublication::factory()->make();
return $publication;
}
} }

View file

@ -0,0 +1,197 @@
<?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')
);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum; use App\Enums\NotificationTypeEnum;
use App\Exceptions\PublishException; use App\Exceptions\PublishException;
@ -16,6 +17,7 @@
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService; use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
@ -95,7 +97,7 @@ public function test_handle_returns_early_when_no_approved_route_articles(): voi
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -114,7 +116,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_route_art
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -132,7 +134,7 @@ public function test_handle_skips_non_approved_route_articles(): void
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -170,10 +172,10 @@ public function test_handle_publishes_oldest_approved_route_article(): void
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id), Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
$extractedData $extractedData
) )
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -200,7 +202,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void
$this->expectException(PublishException::class); $this->expectException(PublishException::class);
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
} }
public function test_handle_skips_publishing_when_last_publication_within_interval(): void public function test_handle_skips_publishing_when_last_publication_within_interval(): void
@ -219,7 +221,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
$publishingServiceMock->shouldNotReceive('publishRouteArticle'); $publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -243,10 +245,10 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -270,10 +272,10 @@ public function test_handle_publishes_when_interval_is_zero(): void
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -297,10 +299,10 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -321,10 +323,10 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(ArticlePublication::factory()->make()); ->andReturn(PublishOutcome::published($this->makePublication()));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertTrue(true); $this->assertTrue(true);
} }
@ -343,10 +345,10 @@ public function test_handle_creates_warning_notification_when_no_publication_cre
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldReceive('publishRouteArticle') $publishingServiceMock->shouldReceive('publishRouteArticle')
->once() ->once()
->andReturn(null); ->andReturn(PublishOutcome::failure('No publication created'));
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertDatabaseHas('notifications', [ $this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
@ -380,7 +382,7 @@ public function test_handle_creates_notification_on_publish_exception(): void
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
try { try {
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
} catch (PublishException) { } catch (PublishException) {
// Expected // Expected
} }
@ -413,4 +415,12 @@ protected function tearDown(): void
Mockery::close(); Mockery::close();
parent::tearDown(); parent::tearDown();
} }
private function makePublication(): ArticlePublication
{
/** @var ArticlePublication $publication */
$publication = ArticlePublication::factory()->make();
return $publication;
}
} }

View file

@ -148,7 +148,7 @@ public function test_sync_resolves_non_numeric_channel_id_via_get_community_id()
->andReturn(42); ->andReturn(42);
$apiMock->shouldReceive('syncChannelPosts') $apiMock->shouldReceive('syncChannelPosts')
->once() ->once()
->with('token', 42, $channel->name); ->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
$logSaverMock = Mockery::mock(LogSaver::class); $logSaverMock = Mockery::mock(LogSaver::class);
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes(); $logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
@ -179,7 +179,7 @@ public function test_sync_passes_resolved_community_id_to_sync_channel_posts():
->andReturn(42); ->andReturn(42);
$apiMock->shouldReceive('syncChannelPosts') $apiMock->shouldReceive('syncChannelPosts')
->once() ->once()
->with('token', 42, $channel->name); ->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
$logSaverMock = Mockery::mock(LogSaver::class); $logSaverMock = Mockery::mock(LogSaver::class);
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes(); $logSaverMock->shouldReceive('info')->zeroOrMoreTimes();

View file

@ -2,7 +2,7 @@
namespace Tests\Unit\Modules\Lemmy\Services; namespace Tests\Unit\Modules\Lemmy\Services;
use App\Enums\PlatformEnum; use App\Models\PlatformChannel;
use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -13,6 +13,13 @@ class LemmyApiServiceTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private ?PlatformChannel $channel = null;
private function syncChannel(): PlatformChannel
{
return $this->channel ??= PlatformChannel::factory()->create();
}
public function test_constructor_sets_instance(): void public function test_constructor_sets_instance(): void
{ {
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
@ -248,7 +255,7 @@ public function test_sync_channel_posts_success(): void
]); ]);
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', $this->syncChannel(), 42);
Http::assertSent(function ($request) { Http::assertSent(function ($request) {
return str_contains($request->url(), '/api/v3/post/list') return str_contains($request->url(), '/api/v3/post/list')
@ -259,18 +266,14 @@ public function test_sync_channel_posts_success(): void
// Verify posts were stored in the database // Verify posts were stored in the database
$this->assertDatabaseHas('platform_channel_posts', [ $this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value, 'platform_channel_id' => $this->syncChannel()->id,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '1', 'post_id' => '1',
'url' => 'https://example.com/1', 'url' => 'https://example.com/1',
'title' => 'Post 1', 'title' => 'Post 1',
]); ]);
$this->assertDatabaseHas('platform_channel_posts', [ $this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value, 'platform_channel_id' => $this->syncChannel()->id,
'channel_id' => '42',
'channel_name' => 'test-community',
'post_id' => '2', 'post_id' => '2',
'url' => 'https://example.com/2', 'url' => 'https://example.com/2',
'title' => 'Post 2', 'title' => 'Post 2',
@ -284,7 +287,7 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
]); ]);
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', $this->syncChannel(), 42);
Http::assertSentCount(1); Http::assertSentCount(1);
$this->assertDatabaseCount('platform_channel_posts', 0); $this->assertDatabaseCount('platform_channel_posts', 0);
@ -297,7 +300,7 @@ public function test_sync_channel_posts_handles_exception(): void
}); });
$service = new LemmyApiService('lemmy.world'); $service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community'); $service->syncChannelPosts('token', $this->syncChannel(), 42);
// Assert that the method completes without throwing // Assert that the method completes without throwing
$this->assertTrue(true); $this->assertTrue(true);

View file

@ -2,7 +2,6 @@
namespace Tests\Unit\Services\Publishing; namespace Tests\Unit\Services\Publishing;
use App\Enums\PlatformEnum;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\Feed; use App\Models\Feed;
@ -99,7 +98,7 @@ public function test_publish_route_article_returns_null_when_no_active_account()
$result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']); $result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);
$this->assertNull($result); $this->assertTrue($result->failed());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
@ -118,7 +117,7 @@ public function test_publish_route_article_successfully_publishes(): void
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$this->assertNotNull($result); $this->assertTrue($result->succeeded());
$this->assertDatabaseHas('article_publications', [ $this->assertDatabaseHas('article_publications', [
'article_id' => $article->id, 'article_id' => $article->id,
'platform_channel_id' => $channel->id, 'platform_channel_id' => $channel->id,
@ -190,7 +189,7 @@ public function test_losing_the_lock_race_skips_without_publishing(): void
// caller's catch block and be recorded as a publish failure. // caller's catch block and be recorded as a publish failure.
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$this->assertNull($result); $this->assertTrue($result->wasSkipped());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
@ -209,7 +208,7 @@ public function test_publish_route_article_handles_publishing_failure_gracefully
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$this->assertNull($result); $this->assertTrue($result->failed());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
@ -219,9 +218,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
// Simulate the URL already being posted to this channel // Simulate the URL already being posted to this channel
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
PlatformEnum::LEMMY, $channel,
(string) $channel->channel_id,
$channel->name,
'999', '999',
$article->url, $article->url,
'Different Title', 'Different Title',
@ -236,7 +233,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
$this->assertNull($result); $this->assertTrue($result->wasSkipped());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
@ -246,9 +243,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
// Simulate the same title already posted with a different URL // Simulate the same title already posted with a different URL
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
PlatformEnum::LEMMY, $channel,
(string) $channel->channel_id,
$channel->name,
'888', '888',
'https://example.com/different-url', 'https://example.com/different-url',
'Breaking News', 'Breaking News',
@ -263,7 +258,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
$this->assertNull($result); $this->assertTrue($result->wasSkipped());
$this->assertDatabaseCount('article_publications', 0); $this->assertDatabaseCount('article_publications', 0);
} }
@ -273,9 +268,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
// Existing post in the channel has a completely different URL and title // Existing post in the channel has a completely different URL and title
PlatformChannelPost::storePost( PlatformChannelPost::storePost(
PlatformEnum::LEMMY, $channel,
(string) $channel->channel_id,
$channel->name,
'777', '777',
'https://example.com/other-article', 'https://example.com/other-article',
'Totally Different Title', 'Totally Different Title',
@ -292,7 +285,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']); $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
$this->assertNotNull($result); $this->assertTrue($result->succeeded());
$this->assertDatabaseHas('article_publications', [ $this->assertDatabaseHas('article_publications', [
'article_id' => $article->id, 'article_id' => $article->id,
'post_id' => 456, 'post_id' => 456,