release/v1.3.7 #127

Merged
myrmidex merged 5 commits from release/v1.3.7 into main 2026-08-06 22:42:12 +02:00
48 changed files with 2062 additions and 596 deletions

View file

@ -42,72 +42,3 @@ jobs:
- name: Tests
run: php artisan test --coverage-clover coverage.xml --coverage-text
- name: Parse coverage
if: github.event_name == 'pull_request'
id: coverage
run: |
COVERAGE=$(php -r '
$xml = simplexml_load_file("coverage.xml");
if ($xml === false || !isset($xml->project->metrics)) {
echo "0";
exit;
}
$metrics = $xml->project->metrics;
$statements = (int) $metrics["statements"];
$covered = (int) $metrics["coveredstatements"];
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COVERAGE: ${{ steps.coverage.outputs.percentage }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
COMMIT_SHA: ${{ github.sha }}
run: |
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
MARKER="<!-- ffr-ci-coverage-report -->"
BODY="${MARKER}
## Code Coverage Report
| Metric | Value |
|--------|-------|
| **Line Coverage** | ${COVERAGE}% |
_Updated by CI — commit ${COMMIT_SHA}_"
# Find existing coverage comment
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API_URL}?limit=50" | \
php -r '
$comments = json_decode(file_get_contents("php://stdin"), true);
if (!is_array($comments)) exit;
foreach ($comments as $c) {
if (str_contains($c["body"], "<!-- ffr-ci-coverage-report -->")) {
echo $c["id"];
exit;
}
}
' || true)
if [ -n "$EXISTING" ]; then
# Update existing comment
curl -sf -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
else
# Create new comment
curl -sf -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
"${API_URL}" > /dev/null
fi

View file

@ -101,6 +101,9 @@ php artisan db:seed --force || echo "Seeders failed or already run"
# Start Horizon in the background
php artisan horizon &
# Start the scheduler in the background
php artisan schedule:work &
# Start FrankenPHP
exec frankenphp run --config /etc/caddy/Caddyfile
EOF

View file

@ -114,6 +114,9 @@ npm run dev &
# Start Horizon (queue worker) in background
php artisan horizon &
# Scheduler left off in dev on purpose; run schedule:work by hand when needed.
# php artisan schedule:work &
# Start FrankenPHP
exec frankenphp run --config /etc/caddy/Caddyfile
EOF

View file

@ -10,7 +10,7 @@
class CreateChannelAction
{
public function execute(string $name, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
public function execute(string $name, int $communityId, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
{
$platformInstance = PlatformInstance::findOrFail($platformInstanceId);
@ -22,10 +22,10 @@ public function execute(string $name, int $platformInstanceId, ?int $languageId
throw new RuntimeException('No active platform accounts found for this instance. Please create a platform account first.');
}
return DB::transaction(function () use ($name, $platformInstanceId, $languageId, $description, $activeAccounts) {
return DB::transaction(function () use ($name, $communityId, $platformInstanceId, $languageId, $description, $activeAccounts) {
$channel = PlatformChannel::create([
'platform_instance_id' => $platformInstanceId,
'channel_id' => $name,
'channel_id' => $communityId,
'name' => $name,
'display_name' => ucfirst($name),
'description' => $description,

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 PUBLISHING = 'publishing';
case PUBLISHED = 'published';
case SKIPPED = 'skipped';
case ERROR = 'error';
}

View file

@ -7,6 +7,8 @@
use App\Http\Resources\PlatformChannelResource;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Services\Platform\CommunityDirectory;
use Exception;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
@ -40,8 +42,12 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
try {
$validated = $request->validated();
$instance = PlatformInstance::query()->findOrFail((int) $validated['platform_instance_id']);
$name = app(CommunityDirectory::class)->name($instance, (int) $validated['channel_id']);
$channel = $createChannelAction->execute(
$validated['name'],
$name,
(int) $validated['channel_id'],
$validated['platform_instance_id'],
$validated['language_id'] ?? null,
$validated['description'] ?? null,

View file

@ -2,6 +2,9 @@
namespace App\Http\Requests;
use App\Models\PlatformInstance;
use App\Services\Platform\CommunityDirectory;
use Exception;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -17,19 +20,22 @@ public function authorize(): bool
*/
public function rules(): array
{
try {
$communityRules = [
Rule::in($this->communityIds()),
Rule::unique('platform_channels', 'channel_id')
->where('platform_instance_id', $this->input('platform_instance_id')),
];
} catch (Exception $e) {
// Falling through to Rule::in([]) would report the community as non-existent
// when the truth is we never reached the instance to check.
$message = 'Could not reach this instance to list its communities: '.$e->getMessage();
$communityRules = [fn ($attribute, $value, $fail) => $fail($message)];
}
return [
'platform_instance_id' => 'required|exists:platform_instances,id',
// name doubles as the Lemmy community slug (CreateChannelAction copies it
// verbatim into channel_id for community lookup at publish time), so it must
// be slug format and unique per instance — matching the Livewire create form.
'name' => [
'required',
'string',
'max:255',
'regex:/^[a-z0-9_]+$/',
Rule::unique('platform_channels', 'name')
->where('platform_instance_id', $this->input('platform_instance_id')),
],
'channel_id' => ['required', 'integer', ...$communityRules],
'language_id' => 'nullable|exists:languages,id',
'description' => 'nullable|string',
];
@ -41,8 +47,24 @@ public function rules(): array
public function messages(): array
{
return [
'name.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
'name.unique' => 'A channel with this name already exists for this instance.',
'channel_id.in' => 'That community does not exist on the selected instance.',
'channel_id.unique' => 'A channel for this community already exists.',
];
}
/**
* @return array<int, int>
*/
private function communityIds(): array
{
$instance = PlatformInstance::query()->find((int) $this->input('platform_instance_id'));
if (! $instance) {
return [];
}
return collect(app(CommunityDirectory::class)->forInstance($instance))
->pluck('id')
->all();
}
}

View file

@ -2,19 +2,14 @@
namespace App\Jobs;
use App\Actions\PublishRouteArticleAction;
use App\Enums\ApprovalStatusEnum;
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\ArticlePublication;
use App\Models\RouteArticle;
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\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
@ -38,7 +33,7 @@ public function __construct()
*
* @throws PublishException
*/
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void
public function handle(PublishRouteArticleAction $publishRouteArticle): void
{
$interval = Setting::getArticlePublishingInterval();
@ -72,52 +67,6 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
]);
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
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;
}
$publishRouteArticle->execute($routeArticle);
}
}

View file

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

View file

@ -2,15 +2,8 @@
namespace App\Listeners;
use App\Enums\LogLevelEnum;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Events\ActionPerformed;
use App\Actions\PublishRouteArticleAction;
use App\Events\RouteArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -19,9 +12,7 @@ class PublishApprovedArticleListener implements ShouldQueue
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService,
private NotificationService $notificationService,
private PublishRouteArticleAction $publishRouteArticle,
) {}
public function handle(RouteArticleApproved $event): void
@ -29,7 +20,6 @@ public function handle(RouteArticleApproved $event): void
$routeArticle = $event->routeArticle;
$article = $routeArticle->article;
// Skip if already published to this channel
if ($article->articlePublications()
->where('platform_channel_id', $routeArticle->platform_channel_id)
->exists()
@ -37,50 +27,10 @@ public function handle(RouteArticleApproved $event): void
return;
}
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
try {
$extractedData = $this->articleFetcher->fetchArticleData($article);
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
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,
);
$this->publishRouteArticle->execute($routeArticle);
} catch (Exception) {
// The action has already recorded the failure and notified.
}
}
}

View file

@ -7,6 +7,8 @@
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Services\Platform\CommunityDirectory;
use Exception;
use Illuminate\Contracts\View\View;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Validation\Rule;
@ -19,10 +21,15 @@ class Channels extends Component
public bool $showCreateModal = false;
public string $newName = '';
public ?int $newCommunityId = null;
public ?int $newPlatformInstanceId = null;
/** @var array<int, array{id: int, name: string, title: string}> */
public array $availableCommunities = [];
public ?string $communityLoadError = null;
public ?int $newLanguageId = null;
public string $newDescription = '';
@ -36,11 +43,44 @@ public function toggle(int $channelId): void
public function openCreateModal(): void
{
$this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']);
$this->reset(['newCommunityId', 'newPlatformInstanceId', 'newLanguageId', 'newDescription', 'availableCommunities', 'communityLoadError']);
$this->resetErrorBag();
$this->showCreateModal = true;
}
public function updatedNewPlatformInstanceId(?int $value): void
{
$this->reset(['newCommunityId', 'availableCommunities', 'communityLoadError']);
if (! $value) {
return;
}
$instance = PlatformInstance::find($value);
if (! $instance) {
return;
}
try {
$this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance);
} catch (Exception $e) {
$this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage();
}
}
public function refreshCommunities(): void
{
$instance = $this->newPlatformInstanceId ? PlatformInstance::find($this->newPlatformInstanceId) : null;
if (! $instance) {
return;
}
app(CommunityDirectory::class)->forget($instance);
$this->updatedNewPlatformInstanceId($this->newPlatformInstanceId);
}
public function closeCreateModal(): void
{
$this->showCreateModal = false;
@ -49,36 +89,33 @@ public function closeCreateModal(): void
public function createChannel(CreateChannelAction $action): void
{
$this->validate([
// name doubles as the Lemmy community slug (used verbatim as channel_id for
// community lookup at publish time), so it must be lowercase slug format.
'newName' => [
'newCommunityId' => [
'required',
'string',
'max:255',
'regex:/^[a-z0-9_]+$/',
Rule::unique('platform_channels', 'name')
'integer',
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
Rule::unique('platform_channels', 'channel_id')
->where('platform_instance_id', $this->newPlatformInstanceId),
],
'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id',
'newLanguageId' => 'nullable|integer|exists:languages,id',
], [
'newName.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
'newName.unique' => 'A channel with this name already exists for this instance.',
'newCommunityId.in' => 'Select a community from this instance.',
'newCommunityId.unique' => 'A channel for this community already exists.',
]);
$name = collect($this->availableCommunities)->firstWhere('id', $this->newCommunityId)['name'] ?? null;
try {
$action->execute(
$this->newName,
$name,
$this->newCommunityId,
$this->newPlatformInstanceId,
$this->newLanguageId,
// Blade textarea binds an empty string when blank; the action expects null for "no description".
$this->newDescription !== '' ? $this->newDescription : null,
);
} catch (UniqueConstraintViolationException $e) {
// Unreachable via this form (the unique rule above catches duplicates first),
// but the (platform_instance_id, channel_id) index can still fire if channel_id
// ever drifts from name. Surface it as a field error instead of a 500.
$this->addError('newName', 'A channel with this name already exists for this instance.');
$this->addError('newCommunityId', 'A channel for this community already exists.');
return;
} catch (RuntimeException $e) {

View file

@ -17,8 +17,10 @@
use App\Models\Route;
use App\Models\Setting;
use App\Services\OnboardingService;
use App\Services\Platform\CommunityDirectory;
use Exception;
use Illuminate\Contracts\View\View;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -49,7 +51,12 @@ class Onboarding extends Component
public string $feedDescription = '';
// Channel form
public string $channelName = '';
public ?int $channelCommunityId = null;
/** @var array<int, array{id: int, name: string, title: string}> */
public array $availableCommunities = [];
public ?string $communityLoadError = null;
public ?int $platformInstanceId = null;
@ -117,10 +124,11 @@ public function mount(): void
// Pre-fill channel form if exists
$channel = PlatformChannel::where('is_active', true)->first();
if ($channel) {
$this->channelName = $channel->name;
$this->platformInstanceId = $channel->platform_instance_id;
$this->channelLanguageId = $channel->language_id;
$this->channelDescription = $channel->description ?? '';
$this->loadCommunities();
$this->channelCommunityId = $channel->channel_id;
}
// Pre-fill route form if exists
@ -252,16 +260,61 @@ public function createFeed(): void
}
}
public function updatedPlatformInstanceId(?int $value): void
{
$this->reset(['channelCommunityId', 'availableCommunities', 'communityLoadError']);
if ($value) {
$this->loadCommunities();
}
}
public function refreshCommunities(): void
{
$instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null;
if (! $instance) {
return;
}
app(CommunityDirectory::class)->forget($instance);
$this->loadCommunities();
}
private function loadCommunities(): void
{
$this->availableCommunities = [];
$this->communityLoadError = null;
$instance = $this->platformInstanceId ? PlatformInstance::find($this->platformInstanceId) : null;
if (! $instance) {
return;
}
try {
$this->availableCommunities = app(CommunityDirectory::class)->forInstance($instance);
} catch (Exception $e) {
$this->communityLoadError = 'Could not reach this instance to list its communities: '.$e->getMessage();
}
}
public function createChannel(): void
{
$this->formErrors = [];
$this->isLoading = true;
$this->validate([
'channelName' => 'required|string|max:255',
'channelCommunityId' => [
'required',
'integer',
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
],
'platformInstanceId' => 'required|exists:platform_instances,id',
'channelLanguageId' => 'required|exists:languages,id',
'channelDescription' => 'nullable|string|max:1000',
], [
'channelCommunityId.in' => 'Select a community from this instance.',
]);
// If language changed, reset feed form
@ -274,11 +327,14 @@ public function createChannel(): void
}
$this->previousChannelLanguageId = $this->channelLanguageId;
$name = collect($this->availableCommunities)->firstWhere('id', $this->channelCommunityId)['name'] ?? null;
try {
$channel = $this->createChannelAction->execute(
$this->channelName,
$this->platformInstanceId,
$this->channelLanguageId,
$name,
(int) $this->channelCommunityId,
(int) $this->platformInstanceId,
$this->channelLanguageId !== null ? (int) $this->channelLanguageId : null,
$this->channelDescription ?: null,
);

View file

@ -15,7 +15,7 @@
* @property int $id
* @property int $platform_instance_id
* @property PlatformInstance $platformInstance
* @property string $channel_id
* @property int $channel_id
* @property string $name
* @property int $language_id
* @property Language|null $language
@ -40,6 +40,7 @@ class PlatformChannel extends Model
protected $casts = [
'is_active' => 'boolean',
'channel_id' => 'integer',
];
/**

View file

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

View file

@ -92,6 +92,10 @@ public function isRejected(): bool
public function approve(): void
{
if ($this->isApproved()) {
return;
}
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
event(new RouteArticleApproved($this));

View file

@ -2,7 +2,7 @@
namespace App\Modules\Lemmy\Services;
use App\Enums\PlatformEnum;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Modules\Lemmy\LemmyRequest;
use Exception;
@ -84,18 +84,35 @@ public function login(string $username, string $password): ?string
}
/**
* Resolve a PlatformChannel.channel_id to a numeric Lemmy community id.
*
* channel_id holds either a community slug (the usual case CreateChannelAction
* copies `name` into it) or an already-numeric community id. Callers that need the
* numeric id should use this rather than reimplementing the check, so the two forms
* stay handled identically everywhere.
* @return array<int, array{id: int, name: string, title: string}>
*/
public function resolveCommunityId(string $channelId, string $token): int
public function listCommunities(?string $token = null): array
{
return is_numeric($channelId)
? (int) $channelId
: $this->getCommunityId($channelId, $token);
$request = new LemmyRequest($this->instance, $token);
$response = $request->get('community/list', [
'type_' => 'Local',
'limit' => 50,
'sort' => 'TopAll',
]);
if (! $response->successful()) {
throw new Exception('Failed to list communities: '.$response->status());
}
/** @var array<int, array<string, mixed>> $communities */
$communities = $response->json('communities') ?? [];
return collect($communities)
->pluck('community')
->reject(fn ($community) => ($community['removed'] ?? false) || ($community['deleted'] ?? false))
->map(fn ($community) => [
'id' => (int) $community['id'],
'name' => (string) $community['name'],
'title' => (string) ($community['title'] ?? $community['name']),
])
->sortBy('name')
->values()
->all();
}
public function getCommunityId(string $communityName, string $token): int
@ -117,12 +134,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 {
$request = new LemmyRequest($this->instance, $token);
$response = $request->get('post/list', [
'community_id' => $platformChannelId,
'community_id' => $communityId,
'limit' => 50,
'sort' => 'New',
]);
@ -130,7 +147,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
if (! $response->successful()) {
logger()->warning('Failed to sync channel posts', [
'status' => $response->status(),
'platform_channel_id' => $platformChannelId,
'platform_channel_id' => $channel->id,
]);
return;
@ -143,9 +160,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
$post = $postData['post'];
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $platformChannelId,
$communityName,
$channel,
(string) $post['id'],
$post['url'] ?? null,
$post['name'] ?? null,
@ -154,14 +169,14 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $
}
logger()->info('Synced channel posts', [
'platform_channel_id' => $platformChannelId,
'platform_channel_id' => $channel->id,
'posts_count' => count($posts),
]);
} catch (Exception $e) {
logger()->error('Exception while syncing channel posts', [
'error' => $e->getMessage(),
'platform_channel_id' => $platformChannelId,
'platform_channel_id' => $channel->id,
]);
}
}

View file

@ -54,13 +54,11 @@ private function createPost(string $token, array $extractedData, PlatformChannel
{
$languageId = $extractedData['language_id'] ?? null;
$communityId = $this->api->resolveCommunityId($channel->channel_id, $token);
return $this->api->createPost(
$token,
$extractedData['title'] ?? 'Untitled',
$extractedData['description'] ?? '',
$communityId,
$channel->channel_id,
$article->url,
$extractedData['thumbnail'] ?? null,
$languageId

View file

@ -0,0 +1,51 @@
<?php
namespace App\Services\Platform;
use App\Models\PlatformInstance;
use App\Modules\Lemmy\Services\LemmyApiService;
use Illuminate\Support\Facades\Cache;
class CommunityDirectory
{
private const TTL_SECONDS = 86400;
/**
* @return array<int, array{id: int, name: string, title: string}>
*/
public function forInstance(PlatformInstance $instance): array
{
return Cache::remember(
self::cacheKey($instance),
self::TTL_SECONDS,
fn () => $this->makeApi($instance->url)->listCommunities()
);
}
public function forget(PlatformInstance $instance): void
{
Cache::forget(self::cacheKey($instance));
}
public function has(PlatformInstance $instance, int $communityId): bool
{
return collect($this->forInstance($instance))
->contains(fn (array $community) => $community['id'] === $communityId);
}
public function name(PlatformInstance $instance, int $communityId): ?string
{
return collect($this->forInstance($instance))
->firstWhere('id', $communityId)['name'] ?? null;
}
protected function makeApi(string $instanceUrl): LemmyApiService
{
return new LemmyApiService($instanceUrl);
}
private static function cacheKey(PlatformInstance $instance): string
{
return "platform:communities:{$instance->id}";
}
}

View file

@ -12,10 +12,16 @@
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
class ArticlePublishingService
{
private const LOCK_TTL_SECONDS = 180;
private const LOCK_WAIT_SECONDS = 15;
public function __construct(private LogSaver $logSaver) {}
/**
@ -33,7 +39,7 @@ protected function makePublisher(mixed $account): LemmyPublisher
*
* @throws PublishException
*/
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): PublishOutcome
{
$article = $routeArticle->article;
$channel = $routeArticle->platformChannel;
@ -54,7 +60,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
'route_article_id' => $routeArticle->id,
]);
return null;
return PublishOutcome::failure('No active account for channel');
}
return $this->publishToChannel($article, $extractedData, $channel, $account);
@ -63,24 +69,51 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
/**
* @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);
try {
return $lock->block(self::LOCK_WAIT_SECONDS, function () use ($article, $extractedData, $channel, $account) {
$alreadyPublished = ArticlePublication::where('article_id', $article->id)
->where('platform_channel_id', $channel->id)
->exists();
if ($alreadyPublished) {
$this->logSaver->info('Skipping duplicate: already published to channel', $channel, [
'article_id' => $article->id,
]);
return PublishOutcome::skipped('Already published to this channel');
}
return $this->doPublishToChannel($article, $extractedData, $channel, $account);
});
} catch (LockTimeoutException $e) {
$this->logSaver->info('Skipping publish: another worker holds the lock', $channel, [
'article_id' => $article->id,
]);
return PublishOutcome::skipped('Another worker is publishing this article');
}
}
/**
* @param array<string, mixed> $extractedData
*/
private function doPublishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
{
try {
// Check if this URL or title was already posted to this channel
$title = $extractedData['title'] ?? $article->title;
if (PlatformChannelPost::duplicateExists(
$channel->platformInstance->platform,
(string) $channel->channel_id,
$article->url,
$title
)) {
if (PlatformChannelPost::duplicateExists($channel, $article->url, $title)) {
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
'article_id' => $article->id,
'url' => $article->url,
'title' => $title,
]);
return null;
return PublishOutcome::skipped('URL or title already posted to this channel');
}
$publisher = $this->makePublisher($account);
@ -100,14 +133,14 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'article_id' => $article->id,
]);
return $publication;
return PublishOutcome::published($publication);
} catch (Exception $e) {
$this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id,
'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

@ -18,7 +18,7 @@ public function definition(): array
{
return [
'platform_instance_id' => PlatformInstance::factory(),
'channel_id' => $this->faker->slug(2),
'channel_id' => $this->faker->unique()->numberBetween(1, 999999),
'name' => $this->faker->words(2, true),
'display_name' => $this->faker->words(2, true),
'language_id' => Language::factory(),
@ -39,7 +39,6 @@ public function community(?string $name = null): static
$communityName = $name ?: $this->faker->word();
return $this->state(fn (array $attributes) => [
'channel_id' => strtolower($communityName),
'name' => $communityName,
'display_name' => ucfirst($communityName),
]);

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

@ -0,0 +1,99 @@
<?php
use App\Models\PlatformAccount;
use App\Modules\Lemmy\Services\LemmyApiService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* channel_id held a community slug, resolved to Lemmy's numeric id on every
* publish and every sync. It now holds that id directly; `name` remains the slug.
*/
return new class extends Migration
{
public function up(): void
{
// Every id is resolved before any DDL runs: these lookups hit the live
// instance and MariaDB will not roll back a schema change if one fails.
$resolved = DB::table('platform_channels')
->orderBy('id')
->get()
->mapWithKeys(fn (object $channel) => [$channel->id => $this->resolve($channel)]);
Schema::table('platform_channels', function (Blueprint $table) {
$table->dropUnique('platform_channels_channel_id_unique');
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->unsignedBigInteger('remote_community_id')->nullable()->after('channel_id');
});
foreach ($resolved as $id => $communityId) {
DB::table('platform_channels')
->where('id', $id)
->update(['remote_community_id' => $communityId]);
}
Schema::table('platform_channels', function (Blueprint $table) {
$table->dropColumn('channel_id');
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->renameColumn('remote_community_id', 'channel_id');
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->unsignedBigInteger('channel_id')->nullable(false)->change();
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
});
}
public function down(): void
{
Schema::table('platform_channels', function (Blueprint $table) {
$table->dropUnique('platform_channels_channel_id_unique');
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->string('channel_id')->change();
});
DB::table('platform_channels')->update(['channel_id' => DB::raw('name')]);
Schema::table('platform_channels', function (Blueprint $table) {
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
});
}
private function resolve(object $channel): int
{
if (is_numeric($channel->channel_id)) {
return (int) $channel->channel_id;
}
$instance = DB::table('platform_instances')->find($channel->platform_instance_id);
if (! $instance) {
throw new RuntimeException("Channel {$channel->id} has no platform instance; cannot resolve its community id.");
}
$account = PlatformAccount::where('instance_url', $instance->url)
->where('is_active', true)
->first();
if (! $account) {
throw new RuntimeException("No active account for {$instance->url}; cannot resolve community '{$channel->channel_id}'.");
}
$api = new LemmyApiService($instance->url);
$token = $api->login($account->username, $account->password);
if (! $token) {
throw new RuntimeException("Could not authenticate against {$instance->url} to resolve community '{$channel->channel_id}'.");
}
return $api->getCommunityId($channel->channel_id, $token);
}
};

View file

@ -1,51 +0,0 @@
#!/bin/sh
# Exit on any error
set -e
# Check required Lemmy environment variables
if [ -z "$LEMMY_INSTANCE" ] || [ -z "$LEMMY_USERNAME" ] || [ -z "$LEMMY_PASSWORD" ] || [ -z "$LEMMY_COMMUNITY" ]; then
echo "ERROR: Missing required Lemmy configuration variables:"
echo " LEMMY_INSTANCE=${LEMMY_INSTANCE:-'(not set)'}"
echo " LEMMY_USERNAME=${LEMMY_USERNAME:-'(not set)'}"
echo " LEMMY_PASSWORD=${LEMMY_PASSWORD:-'(not set)'}"
echo " LEMMY_COMMUNITY=${LEMMY_COMMUNITY:-'(not set)'}"
echo "Please set all required environment variables before starting the application."
exit 1
fi
# Wait for database to be ready
echo "Waiting for database connection..."
until php /docker/wait-for-db.php > /dev/null 2>&1; do
echo "Database not ready, waiting..."
sleep 5
done
echo "Database connection established."
# Wait for Redis to be ready
echo "Waiting for Redis connection..."
until php /docker/wait-for-redis.php > /dev/null 2>&1; do
echo "Redis not ready, waiting..."
sleep 2
done
echo "Redis connection established."
# Substitute environment variables in .env file
echo "Configuring environment variables..."
envsubst < .env > .env.tmp && mv .env.tmp .env
# Run migrations and initial setup
echo "Running database migrations..."
php artisan migrate --force
echo "Dispatching initial sync job..."
php artisan tinker --execute="App\\Jobs\\SyncChannelPostsJob::dispatchForLemmy();"
# Start all services in single container
echo "Starting web server, scheduler, and Horizon..."
php artisan schedule:work &
php artisan horizon &
php artisan serve --host=0.0.0.0 --port=8000 &
# Wait for any process to exit
wait

View file

@ -1,59 +0,0 @@
APP_NAME="Lemmy Poster"
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=$DB_DATABASE
DB_USERNAME=$DB_USERNAME
DB_PASSWORD=$DB_PASSWORD
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# LEMMY SETTINGS
LEMMY_INSTANCE=
LEMMY_USERNAME=
LEMMY_PASSWORD=
LEMMY_COMMUNITY=

View file

@ -1,14 +0,0 @@
#!/usr/bin/env php
<?php
try {
$pdo = new PDO(
'mysql:host=mysql;port=3306;dbname=' . getenv('DB_DATABASE'),
getenv('DB_USERNAME'),
getenv('DB_PASSWORD')
);
echo 'Connected';
exit(0);
} catch (Exception $e) {
exit(1);
}

View file

@ -1,11 +0,0 @@
#!/usr/bin/env php
<?php
try {
$redis = new Redis();
$redis->connect('redis', 6379);
echo 'Connected';
exit(0);
} catch (Exception $e) {
exit(1);
}

View file

@ -162,22 +162,11 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado
@if ($showCreateModal)
<x-form-modal title="Add Channel" close="closeCreateModal">
<form wire:submit="createChannel" class="space-y-4">
<div>
<label for="new-channel-name" class="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="new-channel-name"
wire:model="newName"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
@error('newName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="new-channel-instance" class="block text-sm font-medium text-gray-700">Platform Instance</label>
<select
id="new-channel-instance"
wire:model="newPlatformInstanceId"
wire:model.live="newPlatformInstanceId"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Select an instance</option>
@ -186,8 +175,37 @@ class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:borde
@endforeach
</select>
@error('newPlatformInstanceId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
@if ($communityLoadError)
<p class="mt-1 text-sm text-red-600">{{ $communityLoadError }}</p>
@endif
</div>
@if ($availableCommunities)
<div>
<div class="flex items-center justify-between">
<label for="new-channel-community" class="block text-sm font-medium text-gray-700">Community</label>
<button
type="button"
wire:click="refreshCommunities"
class="text-xs text-blue-600 hover:text-blue-800"
>
Refresh
</button>
</div>
<select
id="new-channel-community"
wire:model="newCommunityId"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Select a community</option>
@foreach ($availableCommunities as $community)
<option value="{{ $community['id'] }}">{{ $community['title'] }} ({{ $community['name'] }})</option>
@endforeach
</select>
@error('newCommunityId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label for="new-channel-language" class="block text-sm font-medium text-gray-700">Language <span class="text-gray-400">(optional)</span></label>
<select

View file

@ -196,29 +196,13 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
</div>
@endif
<div>
<label for="channelName" class="block text-sm font-medium text-gray-700 mb-2">
Community Name
</label>
<input
type="text"
id="channelName"
wire:model="channelName"
placeholder="technology"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<p class="text-sm text-gray-500 mt-1">Enter the community name (without the @ or instance)</p>
@error('channelName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label for="platformInstanceId" class="block text-sm font-medium text-gray-700 mb-2">
Platform Instance
</label>
<select
id="platformInstanceId"
wire:model="platformInstanceId"
wire:model.live="platformInstanceId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
@ -228,8 +212,40 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
@endforeach
</select>
@error('platformInstanceId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
@if ($communityLoadError)
<p class="text-red-600 text-sm mt-1">{{ $communityLoadError }}</p>
@endif
</div>
@if ($availableCommunities)
<div>
<div class="flex items-center justify-between mb-2">
<label for="channelCommunityId" class="block text-sm font-medium text-gray-700">
Community
</label>
<button
type="button"
wire:click="refreshCommunities"
class="text-xs text-blue-600 hover:text-blue-800"
>
Refresh
</button>
</div>
<select
id="channelCommunityId"
wire:model="channelCommunityId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select a community</option>
@foreach ($availableCommunities as $community)
<option value="{{ $community['id'] }}">{{ $community['title'] }} ({{ $community['name'] }})</option>
@endforeach
</select>
@error('channelCommunityId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label for="channelLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language

View file

@ -0,0 +1,82 @@
<?php
namespace Tests\Feature;
use App\Models\PlatformInstance;
use App\Services\Platform\CommunityDirectory;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class CommunityDirectoryTest extends TestCase
{
use RefreshDatabase;
private PlatformInstance $instance;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
Http::fake(['*/api/v3/community/list*' => Http::response([
'communities' => [
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
['community' => ['id' => 42, 'name' => '42', 'title' => 'Forty Two']],
],
])]);
}
private function directory(): CommunityDirectory
{
return app(CommunityDirectory::class);
}
public function test_it_fetches_communities_for_an_instance(): void
{
$this->assertSame([42, 8], collect($this->directory()->forInstance($this->instance))->pluck('id')->all());
}
public function test_it_only_calls_the_instance_once_per_cache_window(): void
{
$this->directory()->forInstance($this->instance);
$this->directory()->forInstance($this->instance);
Http::assertSentCount(1);
}
public function test_forget_causes_a_refetch(): void
{
$this->directory()->forInstance($this->instance);
$this->directory()->forget($this->instance);
$this->directory()->forInstance($this->instance);
Http::assertSentCount(2);
}
public function test_it_caches_per_instance(): void
{
$other = PlatformInstance::factory()->create(['url' => 'https://other.test']);
$this->directory()->forInstance($this->instance);
$this->directory()->forInstance($other);
Http::assertSentCount(2);
}
public function test_has_reports_membership(): void
{
$this->assertTrue($this->directory()->has($this->instance, 8));
$this->assertFalse($this->directory()->has($this->instance, 4242));
}
public function test_a_numerically_named_community_keeps_its_own_id(): void
{
// The community is named "42" but its id is also 42 by coincidence; the
// name must never be read as an id.
$this->assertSame('42', $this->directory()->name($this->instance, 42));
$this->assertTrue($this->directory()->has($this->instance, 42));
}
}

View file

@ -0,0 +1,231 @@
<?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_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(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());
}
/**
* 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());
}
}

View file

@ -6,12 +6,32 @@
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PlatformChannelsControllerTest extends TestCase
{
use RefreshDatabase;
private bool $instanceReachable = true;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
// A stub registered here cannot be overridden by a later Http::fake() for the
// same pattern, so tests toggle the outcome through $instanceReachable instead.
Http::fake(['*/api/v3/community/list*' => fn () => $this->instanceReachable
? Http::response([
'communities' => [
['community' => ['id' => 8, 'name' => 'test_channel', 'title' => 'Test Channel']],
['community' => ['id' => 9, 'name' => 'tech_news', 'title' => 'Tech News']],
],
])
: Http::response([], 503)]);
}
public function test_index_returns_successful_response(): void
{
$instance = PlatformInstance::factory()->create();
@ -56,7 +76,7 @@ public function test_store_creates_platform_channel_successfully(): void
$data = [
'platform_instance_id' => $instance->id,
'name' => 'test_channel',
'channel_id' => 8,
'description' => 'A test channel',
];
@ -85,8 +105,7 @@ public function test_store_creates_platform_channel_successfully(): void
$this->assertDatabaseHas('platform_channels', [
'platform_instance_id' => $instance->id,
'channel_id' => 'test_channel',
'name' => 'test_channel',
'channel_id' => 8,
]);
}
@ -95,14 +114,14 @@ public function test_store_validates_required_fields(): void
$response = $this->postJson('/api/v1/platform-channels', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['platform_instance_id', 'name']);
->assertJsonValidationErrors(['platform_instance_id', 'channel_id']);
}
public function test_store_validates_platform_instance_exists(): void
{
$data = [
'platform_instance_id' => 999,
'name' => 'Test Channel',
'channel_id' => 8,
];
$response = $this->postJson('/api/v1/platform-channels', $data);
@ -111,44 +130,62 @@ public function test_store_validates_platform_instance_exists(): void
->assertJsonValidationErrors(['platform_instance_id']);
}
public function test_store_rejects_non_slug_name(): void
public function test_store_rejects_community_not_on_instance(): void
{
$instance = PlatformInstance::factory()->create();
// name is copied verbatim into channel_id and used as the Lemmy community
// reference, so non-slug values must be rejected at the API boundary too.
$response = $this->postJson('/api/v1/platform-channels', [
'platform_instance_id' => $instance->id,
'name' => 'Tech News',
'channel_id' => 4242,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name']);
->assertJsonValidationErrors(['channel_id']);
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_store_rejects_duplicate_name_for_same_instance(): void
public function test_store_distinguishes_an_unreachable_instance_from_a_missing_community(): void
{
$this->instanceReachable = false;
$instance = PlatformInstance::factory()->create();
PlatformAccount::factory()->create([
'instance_url' => $instance->url,
'is_active' => true,
]);
$response = $this->postJson('/api/v1/platform-channels', [
'platform_instance_id' => $instance->id,
'channel_id' => 8,
]);
$response->assertStatus(422);
$this->assertStringContainsString('Could not reach this instance', $response->json('message'));
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_store_rejects_duplicate_community_for_same_instance(): void
{
$instance = PlatformInstance::factory()->create();
PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id,
'name' => 'tech_news',
'channel_id' => 'tech_news',
'channel_id' => 9,
]);
$response = $this->postJson('/api/v1/platform-channels', [
'platform_instance_id' => $instance->id,
'name' => 'tech_news',
'channel_id' => 9,
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['name']);
->assertJsonValidationErrors(['channel_id']);
$this->assertDatabaseCount('platform_channels', 1);
}
public function test_store_allows_same_name_on_different_instance(): void
public function test_store_allows_same_community_on_different_instance(): void
{
$instanceA = PlatformInstance::factory()->create();
$instanceB = PlatformInstance::factory()->create();
@ -161,12 +198,12 @@ public function test_store_allows_same_name_on_different_instance(): void
PlatformChannel::factory()->create([
'platform_instance_id' => $instanceA->id,
'name' => 'tech_news',
'channel_id' => 'tech_news',
'channel_id' => 9,
]);
$response = $this->postJson('/api/v1/platform-channels', [
'platform_instance_id' => $instanceB->id,
'name' => 'tech_news',
'channel_id' => 9,
]);
$response->assertStatus(201);

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

View file

@ -8,6 +8,9 @@
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
@ -15,6 +18,23 @@ class ChannelsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->fakeCommunities();
}
private function fakeCommunities(): void
{
Http::fake(['*/api/v3/community/list*' => Http::response([
'communities' => [
['community' => ['id' => 8, 'name' => 'tech_community', 'title' => 'Tech Community']],
['community' => ['id' => 9, 'name' => 'other_community', 'title' => 'Other Community']],
],
])]);
}
private function instanceWithActiveAccount(): PlatformInstance
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
@ -50,7 +70,7 @@ public function test_open_create_modal_shows_modal(): void
->assertSet('showCreateModal', true);
}
public function test_create_channel_requires_name(): void
public function test_create_channel_requires_community(): void
{
$instance = $this->instanceWithActiveAccount();
@ -58,14 +78,44 @@ public function test_create_channel_requires_name(): void
->call('openCreateModal')
->set('newPlatformInstanceId', $instance->id)
->call('createChannel')
->assertHasErrors(['newName' => 'required']);
->assertHasErrors(['newCommunityId' => 'required']);
}
public function test_selecting_an_instance_loads_its_communities(): void
{
$instance = $this->instanceWithActiveAccount();
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newPlatformInstanceId', $instance->id)
->assertSet('communityLoadError', null)
->assertCount('availableCommunities', 2);
}
public function test_unreachable_instance_surfaces_an_error_and_no_communities(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://unreachable.test']);
PlatformAccount::factory()->create(['instance_url' => 'https://unreachable.test', 'is_active' => true]);
Cache::flush();
// Http::fake() merges stubs, so setUp's success stub would still win —
// swap the whole fake out instead.
app()->forgetInstance(Factory::class);
Http::swap(new Factory);
Http::fake(['*' => Http::response('nope', 500)]);
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newPlatformInstanceId', $instance->id)
->assertSet('availableCommunities', [])
->assertNotSet('communityLoadError', null);
}
public function test_create_channel_requires_platform_instance(): void
{
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newCommunityId', 8)
->call('createChannel')
->assertHasErrors(['newPlatformInstanceId' => 'required']);
}
@ -77,8 +127,8 @@ public function test_create_channel_succeeds_and_attaches_account(): void
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->set('newLanguageId', $language->id)
->set('newDescription', 'A tech community')
->call('createChannel')
@ -104,8 +154,8 @@ public function test_create_channel_leaves_description_null_when_blank(): void
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel')
->assertHasNoErrors();
@ -115,40 +165,41 @@ public function test_create_channel_leaves_description_null_when_blank(): void
]);
}
public function test_create_channel_rejects_non_slug_name(): void
public function test_create_channel_rejects_a_community_not_on_the_instance(): void
{
$instance = $this->instanceWithActiveAccount();
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'Tech News')
->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 4242)
->call('createChannel')
->assertHasErrors(['newName' => 'regex']);
->assertHasErrors(['newCommunityId' => 'in']);
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_create_channel_rejects_duplicate_name_on_same_instance(): void
public function test_create_channel_rejects_duplicate_community_on_same_instance(): void
{
$instance = $this->instanceWithActiveAccount();
PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id,
'name' => 'tech_community',
'channel_id' => 8,
]);
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel')
->assertHasErrors(['newName' => 'unique'])
->assertHasErrors(['newCommunityId' => 'unique'])
->assertSet('showCreateModal', true);
$this->assertDatabaseCount('platform_channels', 1);
}
public function test_create_channel_allows_same_name_on_different_instance(): void
public function test_create_channel_allows_same_community_on_different_instance(): void
{
$instanceA = $this->instanceWithActiveAccount();
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
@ -159,12 +210,13 @@ public function test_create_channel_allows_same_name_on_different_instance(): vo
PlatformChannel::factory()->create([
'platform_instance_id' => $instanceB->id,
'name' => 'tech_community',
'channel_id' => 8,
]);
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instanceA->id)
->set('newCommunityId', 8)
->call('createChannel')
->assertHasNoErrors();
@ -178,8 +230,8 @@ public function test_create_channel_surfaces_no_active_accounts_error(): void
Livewire::test(Channels::class)
->call('openCreateModal')
->set('newName', 'tech_community')
->set('newPlatformInstanceId', $instance->id)
->set('newCommunityId', 8)
->call('createChannel')
->assertHasErrors('newPlatformInstanceId')
->assertSet('showCreateModal', true);

View file

@ -0,0 +1,202 @@
<?php
namespace Tests\Feature\Livewire;
use App\Jobs\SyncChannelPostsJob;
use App\Livewire\Onboarding;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\TestCase;
class OnboardingTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
Queue::fake();
$this->fakeCommunities();
}
private function fakeCommunities(): void
{
Http::fake(['*/api/v3/community/list*' => Http::response([
'communities' => [
['community' => ['id' => 8, 'name' => 'tech_community', 'title' => 'Tech Community']],
['community' => ['id' => 9, 'name' => 'other_community', 'title' => 'Other Community']],
],
])]);
}
private function instanceWithActiveAccount(): PlatformInstance
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
PlatformAccount::factory()->create([
'instance_url' => 'https://lemmy.world',
'is_active' => true,
]);
return $instance;
}
public function test_selecting_an_instance_loads_its_communities(): void
{
$instance = $this->instanceWithActiveAccount();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->assertSet('communityLoadError', null)
->assertCount('availableCommunities', 2);
}
public function test_changing_the_instance_clears_the_previous_selection(): void
{
$instance = $this->instanceWithActiveAccount();
$other = PlatformInstance::factory()->create(['url' => 'https://other.test']);
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->set('channelCommunityId', 8)
->set('platformInstanceId', $other->id)
->assertSet('channelCommunityId', null);
}
public function test_unreachable_instance_surfaces_an_error_and_no_communities(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://unreachable.test']);
PlatformAccount::factory()->create(['instance_url' => 'https://unreachable.test', 'is_active' => true]);
Cache::flush();
// Http::fake() merges stubs, so setUp's success stub would still win —
// swap the whole fake out instead.
app()->forgetInstance(Factory::class);
Http::swap(new Factory);
Http::fake(['*' => Http::response('nope', 500)]);
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->assertSet('availableCommunities', [])
->assertNotSet('communityLoadError', null);
}
public function test_refresh_communities_refetches_from_the_instance(): void
{
$instance = $this->instanceWithActiveAccount();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->call('refreshCommunities')
->assertCount('availableCommunities', 2);
Http::assertSentCount(2);
}
public function test_create_channel_requires_a_community(): void
{
$instance = $this->instanceWithActiveAccount();
$language = Language::factory()->create();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->set('channelLanguageId', $language->id)
->call('createChannel')
->assertHasErrors(['channelCommunityId' => 'required']);
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_create_channel_rejects_a_community_not_on_the_instance(): void
{
$instance = $this->instanceWithActiveAccount();
$language = Language::factory()->create();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->set('channelCommunityId', 4242)
->set('channelLanguageId', $language->id)
->call('createChannel')
->assertHasErrors(['channelCommunityId' => 'in']);
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_create_channel_stores_the_numeric_id_and_the_community_name(): void
{
$instance = $this->instanceWithActiveAccount();
$language = Language::factory()->create();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->set('channelCommunityId', 8)
->set('channelLanguageId', $language->id)
->set('channelDescription', 'A tech community')
->call('createChannel')
->assertHasNoErrors();
$this->assertDatabaseHas('platform_channels', [
'platform_instance_id' => $instance->id,
'channel_id' => 8,
'name' => 'tech_community',
'description' => 'A tech community',
]);
}
public function test_create_channel_advances_the_wizard_and_syncs_existing_posts(): void
{
$instance = $this->instanceWithActiveAccount();
$language = Language::factory()->create();
Livewire::test(Onboarding::class)
->set('step', 3)
->set('platformInstanceId', $instance->id)
->set('channelCommunityId', 8)
->set('channelLanguageId', $language->id)
->call('createChannel')
->assertHasNoErrors()
->assertSet('step', 4);
Queue::assertPushed(SyncChannelPostsJob::class);
}
public function test_create_channel_reports_when_the_instance_has_no_active_account(): void
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
$language = Language::factory()->create();
Livewire::test(Onboarding::class)
->set('platformInstanceId', $instance->id)
->set('channelCommunityId', 8)
->set('channelLanguageId', $language->id)
->call('createChannel')
->assertHasNoErrors()
->assertSet('formErrors.general', 'No active platform accounts found for this instance. Please create a platform account first.');
$this->assertDatabaseCount('platform_channels', 0);
}
public function test_mount_prefills_the_community_select_from_an_existing_channel(): void
{
$instance = $this->instanceWithActiveAccount();
PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id,
'channel_id' => 8,
'name' => 'tech_community',
'is_active' => true,
]);
Livewire::test(Onboarding::class)
->assertSet('platformInstanceId', $instance->id)
->assertSet('channelCommunityId', 8)
->assertCount('availableCommunities', 2);
}
}

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

@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Models\PlatformAccount;
use App\Models\PlatformInstance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class StoreCommunityIdMigrationTest extends TestCase
{
use RefreshDatabase;
private function runMigration(): void
{
$migration = require database_path('migrations/2024_01_01_000015_store_numeric_community_id_on_platform_channels.php');
$migration->up();
}
private function restoreSlugColumn(): void
{
Schema::table('platform_channels', function (Blueprint $table) {
$table->dropUnique('platform_channels_channel_id_unique');
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->string('channel_id')->change();
});
Schema::table('platform_channels', function (Blueprint $table) {
$table->unique(['platform_instance_id', 'channel_id'], 'platform_channels_channel_id_unique');
});
}
private function seedChannel(PlatformInstance $instance, string $slug): int
{
return DB::table('platform_channels')->insertGetId([
'platform_instance_id' => $instance->id,
'name' => $slug,
'display_name' => ucfirst($slug),
'channel_id' => $slug,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function instanceWithAccount(): PlatformInstance
{
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.test']);
PlatformAccount::factory()->create(['instance_url' => 'https://lemmy.test', 'is_active' => true]);
return $instance;
}
public function test_it_replaces_the_slug_with_the_resolved_community_id(): void
{
$this->restoreSlugColumn();
DB::table('platform_channels')->delete();
$instance = $this->instanceWithAccount();
$id = $this->seedChannel($instance, 'news');
Http::fake([
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
'*/api/v3/community*' => Http::response(['community_view' => ['community' => ['id' => 8]]]),
]);
$this->runMigration();
$this->assertSame(8, (int) DB::table('platform_channels')->where('id', $id)->value('channel_id'));
$this->assertSame('news', DB::table('platform_channels')->where('id', $id)->value('name'));
}
public function test_it_aborts_when_a_community_cannot_be_resolved(): void
{
$this->restoreSlugColumn();
DB::table('platform_channels')->delete();
$instance = $this->instanceWithAccount();
$this->seedChannel($instance, 'gone');
Http::fake([
'*/api/v3/user/login*' => Http::response(['jwt' => 'token']),
'*/api/v3/community*' => Http::response('not found', 404),
]);
$this->expectException(\Exception::class);
$this->runMigration();
}
public function test_it_aborts_when_the_instance_has_no_active_account(): void
{
$this->restoreSlugColumn();
DB::table('platform_channels')->delete();
$instance = PlatformInstance::factory()->create(['url' => 'https://no-account.test']);
$this->seedChannel($instance, 'news');
$this->expectException(\RuntimeException::class);
$this->runMigration();
}
}

View file

@ -31,11 +31,14 @@ public function test_creates_channel_and_attaches_account(): void
]);
$language = Language::factory()->create();
$channel = $this->action->execute('test_community', $instance->id, $language->id, 'A description');
$channel = $this->action->execute(
'test_community',
8,
$instance->id, $language->id, 'A description');
$this->assertInstanceOf(PlatformChannel::class, $channel);
$this->assertEquals('test_community', $channel->name);
$this->assertEquals('test_community', $channel->channel_id);
$this->assertSame(8, $channel->channel_id);
$this->assertEquals('Test_community', $channel->display_name);
$this->assertEquals($instance->id, $channel->platform_instance_id);
$this->assertEquals($language->id, $channel->language_id);
@ -55,7 +58,10 @@ public function test_creates_channel_without_language(): void
'is_active' => true,
]);
$channel = $this->action->execute('test_community', $instance->id);
$channel = $this->action->execute(
'test_community',
8,
$instance->id);
$this->assertNull($channel->language_id);
}
@ -72,7 +78,10 @@ public function test_fails_when_no_active_accounts(): void
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('No active platform accounts found for this instance');
$this->action->execute('test_community', $instance->id);
$this->action->execute(
'test_community',
8,
$instance->id);
}
public function test_fails_when_no_accounts_at_all(): void
@ -81,6 +90,9 @@ public function test_fails_when_no_accounts_at_all(): void
$this->expectException(\RuntimeException::class);
$this->action->execute('test_community', $instance->id);
$this->action->execute(
'test_community',
8,
$instance->id);
}
}

View file

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

View file

@ -133,22 +133,18 @@ public function test_handle_logs_start_message(): void
$this->assertTrue(true);
}
public function test_sync_resolves_non_numeric_channel_id_via_get_community_id(): void
public function test_sync_passes_the_stored_community_id(): void
{
[$channel, $account] = $this->makeSyncableChannel('tech_news');
[$channel, $account] = $this->makeSyncableChannel(42);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('login')
->once()
->with($account->username, $account->password)
->andReturn('token');
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('tech_news', 'token')
->andReturn(42);
$apiMock->shouldReceive('syncChannelPosts')
->once()
->with('token', 42, $channel->name);
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
$logSaverMock = Mockery::mock(LogSaver::class);
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
@ -161,25 +157,18 @@ public function test_sync_resolves_non_numeric_channel_id_via_get_community_id()
$this->addToAssertionCount(1);
}
public function test_sync_passes_resolved_community_id_to_sync_channel_posts(): void
public function test_sync_uses_channel_id_without_a_lookup(): void
{
[$channel, $account] = $this->makeSyncableChannel('42');
[$channel, $account] = $this->makeSyncableChannel(42);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('login')
->once()
->with($account->username, $account->password)
->andReturn('token');
// The slug-vs-numeric branch itself now lives in LemmyApiService::resolveCommunityId
// and is covered by LemmyApiServiceTest; here we only assert the job forwards
// whatever that resolution returns.
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('42', 'token')
->andReturn(42);
$apiMock->shouldReceive('syncChannelPosts')
->once()
->with('token', 42, $channel->name);
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
$logSaverMock = Mockery::mock(LogSaver::class);
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
@ -195,7 +184,7 @@ public function test_sync_passes_resolved_community_id_to_sync_channel_posts():
/**
* @return array{0: PlatformChannel, 1: PlatformAccount}
*/
private function makeSyncableChannel(string $channelId): array
private function makeSyncableChannel(int $channelId): array
{
$platformInstance = PlatformInstance::factory()->create([
'platform' => PlatformEnum::LEMMY,

View file

@ -52,7 +52,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id,
'name' => 'tech',
'channel_id' => 'tech',
'channel_id' => 7,
]);
$this->expectException(UniqueConstraintViolationException::class);
@ -60,7 +60,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id,
'name' => 'tech_alias',
'channel_id' => 'tech',
'channel_id' => 7,
]);
}
@ -71,15 +71,15 @@ public function test_same_channel_id_allowed_across_different_instances(): void
PlatformChannel::factory()->create([
'platform_instance_id' => $instanceA->id,
'channel_id' => 'tech',
'channel_id' => 7,
]);
$second = PlatformChannel::factory()->create([
'platform_instance_id' => $instanceB->id,
'channel_id' => 'tech',
'channel_id' => 7,
]);
$this->assertDatabaseCount('platform_channels', 2);
$this->assertEquals('tech', $second->channel_id);
$this->assertSame(7, $second->channel_id);
}
public function test_belongs_to_platform_instance_relationship(): void
@ -259,7 +259,7 @@ public function test_channel_creation_with_factory(): void
$this->assertInstanceOf(PlatformChannel::class, $channel);
$this->assertNotNull($channel->platform_instance_id);
$this->assertIsString($channel->name);
$this->assertIsString($channel->channel_id);
$this->assertIsInt($channel->channel_id);
$this->assertIsBool($channel->is_active);
}
@ -272,7 +272,7 @@ public function test_channel_creation_with_explicit_values(): void
'platform_instance_id' => $instance->id,
'name' => 'test_channel',
'display_name' => 'Test Channel',
'channel_id' => 'channel_123',
'channel_id' => 123,
'description' => 'A test channel',
'language_id' => $language->id,
'is_active' => false,
@ -281,7 +281,7 @@ public function test_channel_creation_with_explicit_values(): void
$this->assertEquals($instance->id, $channel->platform_instance_id);
$this->assertEquals('test_channel', $channel->name);
$this->assertEquals('Test Channel', $channel->display_name);
$this->assertEquals('channel_123', $channel->channel_id);
$this->assertSame(123, $channel->channel_id);
$this->assertEquals('A test channel', $channel->description);
$this->assertEquals($language->id, $channel->language_id);
$this->assertFalse($channel->is_active);

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Models;
use App\Enums\ApprovalStatusEnum;
use App\Events\RouteArticleApproved;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
@ -10,6 +11,7 @@
use App\Models\RouteArticle;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class RouteArticleTest extends TestCase
@ -60,6 +62,34 @@ public function test_route_article_can_be_approved(): void
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
}
public function test_approving_dispatches_the_approved_event(): void
{
Event::fake([RouteArticleApproved::class]);
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create();
$routeArticle->approve();
Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
}
public function test_re_approving_an_approved_article_does_not_dispatch_again(): void
{
Event::fake([RouteArticleApproved::class]);
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create();
// A double-click, or a UI action racing an API call, calls approve() twice.
// The second must be a no-op: each dispatch queues a publish listener.
$routeArticle->approve();
$routeArticle->approve();
Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
}
public function test_route_article_can_be_rejected(): void
{
/** @var RouteArticle $routeArticle */

View file

@ -2,7 +2,7 @@
namespace Tests\Unit\Modules\Lemmy\Services;
use App\Enums\PlatformEnum;
use App\Models\PlatformChannel;
use App\Modules\Lemmy\Services\LemmyApiService;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -13,6 +13,13 @@ class LemmyApiServiceTest extends TestCase
{
use RefreshDatabase;
private ?PlatformChannel $channel = null;
private function syncChannel(): PlatformChannel
{
return $this->channel ??= PlatformChannel::factory()->create();
}
public function test_constructor_sets_instance(): void
{
$service = new LemmyApiService('lemmy.world');
@ -163,35 +170,40 @@ public function test_get_community_id_success(): void
});
}
public function test_resolve_community_id_looks_up_non_numeric_channel_id(): void
public function test_list_communities_returns_local_communities(): void
{
Http::fake([
'*' => Http::response([
'community_view' => [
'community' => ['id' => 123],
],
], 200),
]);
Http::fake(['*' => Http::response(['communities' => [
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
['community' => ['id' => 9, 'name' => 'memes', 'title' => 'Memes']],
]], 200)]);
$service = new LemmyApiService('lemmy.world');
$id = $service->resolveCommunityId('test-community', 'token');
$communities = (new LemmyApiService('lemmy.world'))->listCommunities();
$this->assertSame(123, $id);
Http::assertSent(fn ($request) => str_contains($request->url(), 'name=test-community'));
$this->assertSame(
[['id' => 9, 'name' => 'memes', 'title' => 'Memes'], ['id' => 8, 'name' => 'news', 'title' => 'News']],
$communities
);
}
public function test_resolve_community_id_uses_numeric_channel_id_without_lookup(): void
public function test_list_communities_omits_removed_and_deleted(): void
{
Http::fake();
Http::fake(['*' => Http::response(['communities' => [
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
['community' => ['id' => 9, 'name' => 'gone', 'title' => 'Gone', 'removed' => true]],
['community' => ['id' => 10, 'name' => 'bye', 'title' => 'Bye', 'deleted' => true]],
]], 200)]);
$service = new LemmyApiService('lemmy.world');
$id = $service->resolveCommunityId('42', 'token');
$this->assertSame([8], collect((new LemmyApiService('lemmy.world'))->listCommunities())->pluck('id')->all());
}
$this->assertSame(42, $id);
public function test_list_communities_throws_on_unsuccessful_response(): void
{
Http::fake(['*' => Http::response('nope', 500)]);
// A numeric channel_id is already the community id — no lookup should happen.
Http::assertNothingSent();
$this->expectException(Exception::class);
$this->expectExceptionMessage('Failed to list communities: 500');
(new LemmyApiService('lemmy.world'))->listCommunities();
}
public function test_get_community_id_throws_on_unsuccessful_response(): void
@ -248,7 +260,7 @@ public function test_sync_channel_posts_success(): void
]);
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
$service->syncChannelPosts('token', $this->syncChannel(), 42);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/api/v3/post/list')
@ -259,18 +271,14 @@ public function test_sync_channel_posts_success(): void
// Verify posts were stored in the database
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'platform_channel_id' => $this->syncChannel()->id,
'post_id' => '1',
'url' => 'https://example.com/1',
'title' => 'Post 1',
]);
$this->assertDatabaseHas('platform_channel_posts', [
'platform' => PlatformEnum::LEMMY->value,
'channel_id' => '42',
'channel_name' => 'test-community',
'platform_channel_id' => $this->syncChannel()->id,
'post_id' => '2',
'url' => 'https://example.com/2',
'title' => 'Post 2',
@ -284,7 +292,7 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
]);
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
$service->syncChannelPosts('token', $this->syncChannel(), 42);
Http::assertSentCount(1);
$this->assertDatabaseCount('platform_channel_posts', 0);
@ -297,7 +305,7 @@ public function test_sync_channel_posts_handles_exception(): void
});
$service = new LemmyApiService('lemmy.world');
$service->syncChannelPosts('token', 42, 'test-community');
$service->syncChannelPosts('token', $this->syncChannel(), 42);
// Assert that the method completes without throwing
$this->assertTrue(true);

View file

@ -55,7 +55,7 @@ public function test_publish_to_channel_with_all_data(): void
]);
$channel = PlatformChannel::factory()->make([
'channel_id' => '42',
'channel_id' => 42,
]);
$extractedData = [
@ -76,10 +76,6 @@ public function test_publish_to_channel_with_all_data(): void
// Mock LemmyApiService
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('42', 'test-token')
->andReturn(42);
$apiMock->shouldReceive('createPost')
->once()
->with(
@ -117,7 +113,7 @@ public function test_publish_to_channel_with_minimal_data(): void
]);
$channel = PlatformChannel::factory()->make([
'channel_id' => '24',
'channel_id' => 24,
]);
$extractedData = [];
@ -133,10 +129,6 @@ public function test_publish_to_channel_with_minimal_data(): void
// Mock LemmyApiService
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('24', 'minimal-token')
->andReturn(24);
$apiMock->shouldReceive('createPost')
->once()
->with(
@ -174,7 +166,7 @@ public function test_publish_to_channel_without_thumbnail(): void
]);
$channel = PlatformChannel::factory()->make([
'channel_id' => '33',
'channel_id' => 33,
]);
$extractedData = [
@ -193,10 +185,6 @@ public function test_publish_to_channel_without_thumbnail(): void
// Mock LemmyApiService
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('33', 'no-thumb-token')
->andReturn(33);
$apiMock->shouldReceive('createPost')
->once()
->with(
@ -260,7 +248,7 @@ public function test_publish_to_channel_throws_api_exception(): void
]);
$channel = PlatformChannel::factory()->make([
'channel_id' => '42',
'channel_id' => 42,
]);
$extractedData = [
@ -278,10 +266,6 @@ public function test_publish_to_channel_throws_api_exception(): void
// Mock LemmyApiService to throw exception
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('42', 'test-token')
->andReturn(42);
$apiMock->shouldReceive('createPost')
->once()
->andThrow(new Exception('API Error'));
@ -311,7 +295,7 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
]);
$channel = PlatformChannel::factory()->make([
'channel_id' => 'string-42',
'channel_id' => 42,
]);
$extractedData = [
@ -327,10 +311,6 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
// Mock LemmyApiService - should resolve non-numeric channel_id to a community id
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('resolveCommunityId')
->once()
->with('string-42', 'token')
->andReturn(42);
$apiMock->shouldReceive('createPost')
->once()
->with(

View file

@ -2,8 +2,8 @@
namespace Tests\Unit\Services\Publishing;
use App\Enums\PlatformEnum;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
@ -15,7 +15,10 @@
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;
@ -95,7 +98,7 @@ public function test_publish_route_article_returns_null_when_no_active_account()
$result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);
$this->assertNull($result);
$this->assertTrue($result->failed());
$this->assertDatabaseCount('article_publications', 0);
}
@ -114,7 +117,7 @@ public function test_publish_route_article_successfully_publishes(): void
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$this->assertNotNull($result);
$this->assertTrue($result->succeeded());
$this->assertDatabaseHas('article_publications', [
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
@ -123,6 +126,73 @@ public function test_publish_route_article_successfully_publishes(): void
]);
}
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();
@ -138,7 +208,7 @@ public function test_publish_route_article_handles_publishing_failure_gracefully
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
$this->assertNull($result);
$this->assertTrue($result->failed());
$this->assertDatabaseCount('article_publications', 0);
}
@ -148,9 +218,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
// Simulate the URL already being posted to this channel
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
$channel,
'999',
$article->url,
'Different Title',
@ -165,7 +233,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
$this->assertNull($result);
$this->assertTrue($result->wasSkipped());
$this->assertDatabaseCount('article_publications', 0);
}
@ -175,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
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
$channel,
'888',
'https://example.com/different-url',
'Breaking News',
@ -192,7 +258,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
$this->assertNull($result);
$this->assertTrue($result->wasSkipped());
$this->assertDatabaseCount('article_publications', 0);
}
@ -202,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
PlatformChannelPost::storePost(
PlatformEnum::LEMMY,
(string) $channel->channel_id,
$channel->name,
$channel,
'777',
'https://example.com/other-article',
'Totally Different Title',
@ -221,7 +285,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
$this->assertNotNull($result);
$this->assertTrue($result->succeeded());
$this->assertDatabaseHas('article_publications', [
'article_id' => $article->id,
'post_id' => 456,