Compare commits
No commits in common. "d0d81e524ece6af129345bb59e529c9d9e98ae2e" and "923a0736a89242e8d989c136b16704cbc1be2a54" have entirely different histories.
d0d81e524e
...
923a0736a8
47 changed files with 527 additions and 2062 deletions
|
|
@ -101,9 +101,6 @@ php artisan db:seed --force || echo "Seeders failed or already run"
|
||||||
# Start Horizon in the background
|
# Start Horizon in the background
|
||||||
php artisan horizon &
|
php artisan horizon &
|
||||||
|
|
||||||
# Start the scheduler in the background
|
|
||||||
php artisan schedule:work &
|
|
||||||
|
|
||||||
# Start FrankenPHP
|
# Start FrankenPHP
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,6 @@ npm run dev &
|
||||||
# Start Horizon (queue worker) in background
|
# Start Horizon (queue worker) in background
|
||||||
php artisan horizon &
|
php artisan horizon &
|
||||||
|
|
||||||
# Scheduler left off in dev on purpose; run schedule:work by hand when needed.
|
|
||||||
# php artisan schedule:work &
|
|
||||||
|
|
||||||
# Start FrankenPHP
|
# Start FrankenPHP
|
||||||
exec frankenphp run --config /etc/caddy/Caddyfile
|
exec frankenphp run --config /etc/caddy/Caddyfile
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
class CreateChannelAction
|
class CreateChannelAction
|
||||||
{
|
{
|
||||||
public function execute(string $name, int $communityId, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
|
public function execute(string $name, int $platformInstanceId, ?int $languageId = null, ?string $description = null): PlatformChannel
|
||||||
{
|
{
|
||||||
$platformInstance = PlatformInstance::findOrFail($platformInstanceId);
|
$platformInstance = PlatformInstance::findOrFail($platformInstanceId);
|
||||||
|
|
||||||
|
|
@ -22,10 +22,10 @@ public function execute(string $name, int $communityId, int $platformInstanceId,
|
||||||
throw new RuntimeException('No active platform accounts found for this instance. Please create a platform account first.');
|
throw new RuntimeException('No active platform accounts found for this instance. Please create a platform account first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($name, $communityId, $platformInstanceId, $languageId, $description, $activeAccounts) {
|
return DB::transaction(function () use ($name, $platformInstanceId, $languageId, $description, $activeAccounts) {
|
||||||
$channel = PlatformChannel::create([
|
$channel = PlatformChannel::create([
|
||||||
'platform_instance_id' => $platformInstanceId,
|
'platform_instance_id' => $platformInstanceId,
|
||||||
'channel_id' => $communityId,
|
'channel_id' => $name,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'display_name' => ucfirst($name),
|
'display_name' => ucfirst($name),
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<?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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,5 @@ enum PublishStatusEnum: string
|
||||||
case UNPUBLISHED = 'unpublished';
|
case UNPUBLISHED = 'unpublished';
|
||||||
case PUBLISHING = 'publishing';
|
case PUBLISHING = 'publishing';
|
||||||
case PUBLISHED = 'published';
|
case PUBLISHED = 'published';
|
||||||
case SKIPPED = 'skipped';
|
|
||||||
case ERROR = 'error';
|
case ERROR = 'error';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
use App\Http\Resources\PlatformChannelResource;
|
use App\Http\Resources\PlatformChannelResource;
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
|
||||||
use App\Services\Platform\CommunityDirectory;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
@ -42,12 +40,8 @@ public function store(StorePlatformChannelRequest $request, CreateChannelAction
|
||||||
try {
|
try {
|
||||||
$validated = $request->validated();
|
$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(
|
$channel = $createChannelAction->execute(
|
||||||
$name,
|
$validated['name'],
|
||||||
(int) $validated['channel_id'],
|
|
||||||
$validated['platform_instance_id'],
|
$validated['platform_instance_id'],
|
||||||
$validated['language_id'] ?? null,
|
$validated['language_id'] ?? null,
|
||||||
$validated['description'] ?? null,
|
$validated['description'] ?? null,
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
use App\Models\PlatformInstance;
|
|
||||||
use App\Services\Platform\CommunityDirectory;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
|
@ -20,22 +17,19 @@ public function authorize(): bool
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
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 [
|
return [
|
||||||
'platform_instance_id' => 'required|exists:platform_instances,id',
|
'platform_instance_id' => 'required|exists:platform_instances,id',
|
||||||
'channel_id' => ['required', 'integer', ...$communityRules],
|
// 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')),
|
||||||
|
],
|
||||||
'language_id' => 'nullable|exists:languages,id',
|
'language_id' => 'nullable|exists:languages,id',
|
||||||
'description' => 'nullable|string',
|
'description' => 'nullable|string',
|
||||||
];
|
];
|
||||||
|
|
@ -47,24 +41,8 @@ public function rules(): array
|
||||||
public function messages(): array
|
public function messages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'channel_id.in' => 'That community does not exist on the selected instance.',
|
'name.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
|
||||||
'channel_id.unique' => 'A channel for this community already exists.',
|
'name.unique' => 'A channel with this name already exists for this instance.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Actions\PublishRouteArticleAction;
|
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Enums\LogLevelEnum;
|
use App\Enums\LogLevelEnum;
|
||||||
|
use App\Enums\NotificationSeverityEnum;
|
||||||
|
use App\Enums\NotificationTypeEnum;
|
||||||
|
use App\Enums\PublishStatusEnum;
|
||||||
use App\Events\ActionPerformed;
|
use App\Events\ActionPerformed;
|
||||||
use App\Exceptions\PublishException;
|
use App\Exceptions\PublishException;
|
||||||
use App\Models\ArticlePublication;
|
use App\Models\ArticlePublication;
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Notification\NotificationService;
|
||||||
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
@ -33,7 +38,7 @@ public function __construct()
|
||||||
*
|
*
|
||||||
* @throws PublishException
|
* @throws PublishException
|
||||||
*/
|
*/
|
||||||
public function handle(PublishRouteArticleAction $publishRouteArticle): void
|
public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void
|
||||||
{
|
{
|
||||||
$interval = Setting::getArticlePublishingInterval();
|
$interval = Setting::getArticlePublishingInterval();
|
||||||
|
|
||||||
|
|
@ -67,6 +72,52 @@ public function handle(PublishRouteArticleAction $publishRouteArticle): void
|
||||||
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
|
'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$publishRouteArticle->execute($routeArticle);
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,9 @@ private function syncLemmyChannelPosts(LogSaver $logSaver): void
|
||||||
$api = $this->makeApiService($this->channel->platformInstance->url);
|
$api = $this->makeApiService($this->channel->platformInstance->url);
|
||||||
$token = $this->getAuthToken($api, $account);
|
$token = $this->getAuthToken($api, $account);
|
||||||
|
|
||||||
$api->syncChannelPosts($token, $this->channel, $this->channel->channel_id);
|
$communityId = $api->resolveCommunityId($this->channel->channel_id, $token);
|
||||||
|
|
||||||
|
$api->syncChannelPosts($token, $communityId, $this->channel->name);
|
||||||
|
|
||||||
$logSaver->info('Channel posts synced successfully', $this->channel);
|
$logSaver->info('Channel posts synced successfully', $this->channel);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,15 @@
|
||||||
|
|
||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Actions\PublishRouteArticleAction;
|
use App\Enums\LogLevelEnum;
|
||||||
|
use App\Enums\NotificationSeverityEnum;
|
||||||
|
use App\Enums\NotificationTypeEnum;
|
||||||
|
use App\Enums\PublishStatusEnum;
|
||||||
|
use App\Events\ActionPerformed;
|
||||||
use App\Events\RouteArticleApproved;
|
use App\Events\RouteArticleApproved;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Notification\NotificationService;
|
||||||
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
||||||
|
|
@ -12,7 +19,9 @@ class PublishApprovedArticleListener implements ShouldQueue
|
||||||
public string $queue = 'publishing';
|
public string $queue = 'publishing';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private PublishRouteArticleAction $publishRouteArticle,
|
private ArticleFetcher $articleFetcher,
|
||||||
|
private ArticlePublishingService $publishingService,
|
||||||
|
private NotificationService $notificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(RouteArticleApproved $event): void
|
public function handle(RouteArticleApproved $event): void
|
||||||
|
|
@ -20,6 +29,7 @@ public function handle(RouteArticleApproved $event): void
|
||||||
$routeArticle = $event->routeArticle;
|
$routeArticle = $event->routeArticle;
|
||||||
$article = $routeArticle->article;
|
$article = $routeArticle->article;
|
||||||
|
|
||||||
|
// Skip if already published to this channel
|
||||||
if ($article->articlePublications()
|
if ($article->articlePublications()
|
||||||
->where('platform_channel_id', $routeArticle->platform_channel_id)
|
->where('platform_channel_id', $routeArticle->platform_channel_id)
|
||||||
->exists()
|
->exists()
|
||||||
|
|
@ -27,10 +37,50 @@ public function handle(RouteArticleApproved $event): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->publishRouteArticle->execute($routeArticle);
|
$extractedData = $this->articleFetcher->fetchArticleData($article);
|
||||||
} catch (Exception) {
|
$publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData);
|
||||||
// The action has already recorded the failure and notified.
|
|
||||||
|
if ($publication) {
|
||||||
|
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||||
|
|
||||||
|
ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'title' => $article->title,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||||
|
|
||||||
|
ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'title' => $article->title,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->notificationService->send(
|
||||||
|
NotificationTypeEnum::PUBLISH_FAILED,
|
||||||
|
NotificationSeverityEnum::WARNING,
|
||||||
|
"Publish failed: {$article->title}",
|
||||||
|
'No publication was created for this article. Check channel routing configuration.',
|
||||||
|
$article,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||||
|
|
||||||
|
ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->notificationService->send(
|
||||||
|
NotificationTypeEnum::PUBLISH_FAILED,
|
||||||
|
NotificationSeverityEnum::ERROR,
|
||||||
|
"Publish failed: {$article->title}",
|
||||||
|
$e->getMessage(),
|
||||||
|
$article,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
use App\Services\Platform\CommunityDirectory;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
@ -21,15 +19,10 @@ class Channels extends Component
|
||||||
|
|
||||||
public bool $showCreateModal = false;
|
public bool $showCreateModal = false;
|
||||||
|
|
||||||
public ?int $newCommunityId = null;
|
public string $newName = '';
|
||||||
|
|
||||||
public ?int $newPlatformInstanceId = 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 ?int $newLanguageId = null;
|
||||||
|
|
||||||
public string $newDescription = '';
|
public string $newDescription = '';
|
||||||
|
|
@ -43,44 +36,11 @@ public function toggle(int $channelId): void
|
||||||
|
|
||||||
public function openCreateModal(): void
|
public function openCreateModal(): void
|
||||||
{
|
{
|
||||||
$this->reset(['newCommunityId', 'newPlatformInstanceId', 'newLanguageId', 'newDescription', 'availableCommunities', 'communityLoadError']);
|
$this->reset(['newName', 'newPlatformInstanceId', 'newLanguageId', 'newDescription']);
|
||||||
$this->resetErrorBag();
|
$this->resetErrorBag();
|
||||||
$this->showCreateModal = true;
|
$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
|
public function closeCreateModal(): void
|
||||||
{
|
{
|
||||||
$this->showCreateModal = false;
|
$this->showCreateModal = false;
|
||||||
|
|
@ -89,33 +49,36 @@ public function closeCreateModal(): void
|
||||||
public function createChannel(CreateChannelAction $action): void
|
public function createChannel(CreateChannelAction $action): void
|
||||||
{
|
{
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'newCommunityId' => [
|
// 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' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'string',
|
||||||
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
|
'max:255',
|
||||||
Rule::unique('platform_channels', 'channel_id')
|
'regex:/^[a-z0-9_]+$/',
|
||||||
|
Rule::unique('platform_channels', 'name')
|
||||||
->where('platform_instance_id', $this->newPlatformInstanceId),
|
->where('platform_instance_id', $this->newPlatformInstanceId),
|
||||||
],
|
],
|
||||||
'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id',
|
'newPlatformInstanceId' => 'required|integer|exists:platform_instances,id',
|
||||||
'newLanguageId' => 'nullable|integer|exists:languages,id',
|
'newLanguageId' => 'nullable|integer|exists:languages,id',
|
||||||
], [
|
], [
|
||||||
'newCommunityId.in' => 'Select a community from this instance.',
|
'newName.regex' => 'The name must be a valid community slug (lowercase letters, numbers, and underscores only).',
|
||||||
'newCommunityId.unique' => 'A channel for this community already exists.',
|
'newName.unique' => 'A channel with this name already exists for this instance.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$name = collect($this->availableCommunities)->firstWhere('id', $this->newCommunityId)['name'] ?? null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$action->execute(
|
$action->execute(
|
||||||
$name,
|
$this->newName,
|
||||||
$this->newCommunityId,
|
|
||||||
$this->newPlatformInstanceId,
|
$this->newPlatformInstanceId,
|
||||||
$this->newLanguageId,
|
$this->newLanguageId,
|
||||||
// Blade textarea binds an empty string when blank; the action expects null for "no description".
|
// Blade textarea binds an empty string when blank; the action expects null for "no description".
|
||||||
$this->newDescription !== '' ? $this->newDescription : null,
|
$this->newDescription !== '' ? $this->newDescription : null,
|
||||||
);
|
);
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
$this->addError('newCommunityId', 'A channel for this community already exists.');
|
// 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.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,8 @@
|
||||||
use App\Models\Route;
|
use App\Models\Route;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Services\OnboardingService;
|
use App\Services\OnboardingService;
|
||||||
use App\Services\Platform\CommunityDirectory;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
@ -51,12 +49,7 @@ class Onboarding extends Component
|
||||||
public string $feedDescription = '';
|
public string $feedDescription = '';
|
||||||
|
|
||||||
// Channel form
|
// Channel form
|
||||||
public ?int $channelCommunityId = null;
|
public string $channelName = '';
|
||||||
|
|
||||||
/** @var array<int, array{id: int, name: string, title: string}> */
|
|
||||||
public array $availableCommunities = [];
|
|
||||||
|
|
||||||
public ?string $communityLoadError = null;
|
|
||||||
|
|
||||||
public ?int $platformInstanceId = null;
|
public ?int $platformInstanceId = null;
|
||||||
|
|
||||||
|
|
@ -124,11 +117,10 @@ public function mount(): void
|
||||||
// Pre-fill channel form if exists
|
// Pre-fill channel form if exists
|
||||||
$channel = PlatformChannel::where('is_active', true)->first();
|
$channel = PlatformChannel::where('is_active', true)->first();
|
||||||
if ($channel) {
|
if ($channel) {
|
||||||
|
$this->channelName = $channel->name;
|
||||||
$this->platformInstanceId = $channel->platform_instance_id;
|
$this->platformInstanceId = $channel->platform_instance_id;
|
||||||
$this->channelLanguageId = $channel->language_id;
|
$this->channelLanguageId = $channel->language_id;
|
||||||
$this->channelDescription = $channel->description ?? '';
|
$this->channelDescription = $channel->description ?? '';
|
||||||
$this->loadCommunities();
|
|
||||||
$this->channelCommunityId = $channel->channel_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fill route form if exists
|
// Pre-fill route form if exists
|
||||||
|
|
@ -260,61 +252,16 @@ 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
|
public function createChannel(): void
|
||||||
{
|
{
|
||||||
$this->formErrors = [];
|
$this->formErrors = [];
|
||||||
$this->isLoading = true;
|
$this->isLoading = true;
|
||||||
|
|
||||||
$this->validate([
|
$this->validate([
|
||||||
'channelCommunityId' => [
|
'channelName' => 'required|string|max:255',
|
||||||
'required',
|
|
||||||
'integer',
|
|
||||||
Rule::in(collect($this->availableCommunities)->pluck('id')->all()),
|
|
||||||
],
|
|
||||||
'platformInstanceId' => 'required|exists:platform_instances,id',
|
'platformInstanceId' => 'required|exists:platform_instances,id',
|
||||||
'channelLanguageId' => 'required|exists:languages,id',
|
'channelLanguageId' => 'required|exists:languages,id',
|
||||||
'channelDescription' => 'nullable|string|max:1000',
|
'channelDescription' => 'nullable|string|max:1000',
|
||||||
], [
|
|
||||||
'channelCommunityId.in' => 'Select a community from this instance.',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If language changed, reset feed form
|
// If language changed, reset feed form
|
||||||
|
|
@ -327,14 +274,11 @@ public function createChannel(): void
|
||||||
}
|
}
|
||||||
$this->previousChannelLanguageId = $this->channelLanguageId;
|
$this->previousChannelLanguageId = $this->channelLanguageId;
|
||||||
|
|
||||||
$name = collect($this->availableCommunities)->firstWhere('id', $this->channelCommunityId)['name'] ?? null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$channel = $this->createChannelAction->execute(
|
$channel = $this->createChannelAction->execute(
|
||||||
$name,
|
$this->channelName,
|
||||||
(int) $this->channelCommunityId,
|
$this->platformInstanceId,
|
||||||
(int) $this->platformInstanceId,
|
$this->channelLanguageId,
|
||||||
$this->channelLanguageId !== null ? (int) $this->channelLanguageId : null,
|
|
||||||
$this->channelDescription ?: null,
|
$this->channelDescription ?: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property int $platform_instance_id
|
* @property int $platform_instance_id
|
||||||
* @property PlatformInstance $platformInstance
|
* @property PlatformInstance $platformInstance
|
||||||
* @property int $channel_id
|
* @property string $channel_id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property int $language_id
|
* @property int $language_id
|
||||||
* @property Language|null $language
|
* @property Language|null $language
|
||||||
|
|
@ -40,7 +40,6 @@ class PlatformChannel extends Model
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
'channel_id' => 'integer',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @method static where(string $string, PlatformEnum $platform)
|
||||||
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
|
* @method static updateOrCreate(array<string, mixed> $array, array<string, mixed> $array1)
|
||||||
*/
|
*/
|
||||||
class PlatformChannelPost extends Model
|
class PlatformChannelPost extends Model
|
||||||
|
|
@ -16,7 +17,9 @@ class PlatformChannelPost extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'platform_channel_id',
|
'platform',
|
||||||
|
'channel_id',
|
||||||
|
'channel_name',
|
||||||
'post_id',
|
'post_id',
|
||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
|
|
@ -30,24 +33,26 @@ protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'posted_at' => 'datetime',
|
'posted_at' => 'datetime',
|
||||||
|
'platform' => PlatformEnum::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static function urlExists(PlatformEnum $platform, string $channelId, string $url): bool
|
||||||
* @return BelongsTo<PlatformChannel, $this>
|
|
||||||
*/
|
|
||||||
public function platformChannel(): BelongsTo
|
|
||||||
{
|
{
|
||||||
return $this->belongsTo(PlatformChannel::class);
|
return self::where('platform', $platform)
|
||||||
|
->where('channel_id', $channelId)
|
||||||
|
->where('url', $url)
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function duplicateExists(PlatformChannel $channel, ?string $url, ?string $title): bool
|
public static function duplicateExists(PlatformEnum $platform, string $channelId, ?string $url, ?string $title): bool
|
||||||
{
|
{
|
||||||
if (! $url && ! $title) {
|
if (! $url && ! $title) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::where('platform_channel_id', $channel->id)
|
return self::where('platform', $platform)
|
||||||
|
->where('channel_id', $channelId)
|
||||||
->where(function ($query) use ($url, $title) {
|
->where(function ($query) use ($url, $title) {
|
||||||
if ($url) {
|
if ($url) {
|
||||||
$query->orWhere('url', $url);
|
$query->orWhere('url', $url);
|
||||||
|
|
@ -59,14 +64,16 @@ public static function duplicateExists(PlatformChannel $channel, ?string $url, ?
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function storePost(PlatformChannel $channel, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
|
public static function storePost(PlatformEnum $platform, string $channelId, ?string $channelName, string $postId, ?string $url, ?string $title, ?\DateTime $postedAt = null): self
|
||||||
{
|
{
|
||||||
return self::updateOrCreate(
|
return self::updateOrCreate(
|
||||||
[
|
[
|
||||||
'platform_channel_id' => $channel->id,
|
'platform' => $platform,
|
||||||
|
'channel_id' => $channelId,
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
'channel_name' => $channelName,
|
||||||
'url' => $url,
|
'url' => $url,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'posted_at' => $postedAt ?? now(),
|
'posted_at' => $postedAt ?? now(),
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,6 @@ public function isRejected(): bool
|
||||||
|
|
||||||
public function approve(): void
|
public function approve(): void
|
||||||
{
|
{
|
||||||
if ($this->isApproved()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
||||||
|
|
||||||
event(new RouteArticleApproved($this));
|
event(new RouteArticleApproved($this));
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Modules\Lemmy\Services;
|
namespace App\Modules\Lemmy\Services;
|
||||||
|
|
||||||
use App\Models\PlatformChannel;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Models\PlatformChannelPost;
|
use App\Models\PlatformChannelPost;
|
||||||
use App\Modules\Lemmy\LemmyRequest;
|
use App\Modules\Lemmy\LemmyRequest;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -84,35 +84,18 @@ public function login(string $username, string $password): ?string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{id: int, name: string, title: 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.
|
||||||
*/
|
*/
|
||||||
public function listCommunities(?string $token = null): array
|
public function resolveCommunityId(string $channelId, string $token): int
|
||||||
{
|
{
|
||||||
$request = new LemmyRequest($this->instance, $token);
|
return is_numeric($channelId)
|
||||||
$response = $request->get('community/list', [
|
? (int) $channelId
|
||||||
'type_' => 'Local',
|
: $this->getCommunityId($channelId, $token);
|
||||||
'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
|
public function getCommunityId(string $communityName, string $token): int
|
||||||
|
|
@ -134,12 +117,12 @@ public function getCommunityId(string $communityName, string $token): int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncChannelPosts(string $token, PlatformChannel $channel, int $communityId): void
|
public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$request = new LemmyRequest($this->instance, $token);
|
$request = new LemmyRequest($this->instance, $token);
|
||||||
$response = $request->get('post/list', [
|
$response = $request->get('post/list', [
|
||||||
'community_id' => $communityId,
|
'community_id' => $platformChannelId,
|
||||||
'limit' => 50,
|
'limit' => 50,
|
||||||
'sort' => 'New',
|
'sort' => 'New',
|
||||||
]);
|
]);
|
||||||
|
|
@ -147,7 +130,7 @@ public function syncChannelPosts(string $token, PlatformChannel $channel, int $c
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
logger()->warning('Failed to sync channel posts', [
|
logger()->warning('Failed to sync channel posts', [
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
'platform_channel_id' => $channel->id,
|
'platform_channel_id' => $platformChannelId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
@ -160,7 +143,9 @@ public function syncChannelPosts(string $token, PlatformChannel $channel, int $c
|
||||||
$post = $postData['post'];
|
$post = $postData['post'];
|
||||||
|
|
||||||
PlatformChannelPost::storePost(
|
PlatformChannelPost::storePost(
|
||||||
$channel,
|
PlatformEnum::LEMMY,
|
||||||
|
(string) $platformChannelId,
|
||||||
|
$communityName,
|
||||||
(string) $post['id'],
|
(string) $post['id'],
|
||||||
$post['url'] ?? null,
|
$post['url'] ?? null,
|
||||||
$post['name'] ?? null,
|
$post['name'] ?? null,
|
||||||
|
|
@ -169,14 +154,14 @@ public function syncChannelPosts(string $token, PlatformChannel $channel, int $c
|
||||||
}
|
}
|
||||||
|
|
||||||
logger()->info('Synced channel posts', [
|
logger()->info('Synced channel posts', [
|
||||||
'platform_channel_id' => $channel->id,
|
'platform_channel_id' => $platformChannelId,
|
||||||
'posts_count' => count($posts),
|
'posts_count' => count($posts),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Exception while syncing channel posts', [
|
logger()->error('Exception while syncing channel posts', [
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'platform_channel_id' => $channel->id,
|
'platform_channel_id' => $platformChannelId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,13 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
||||||
{
|
{
|
||||||
$languageId = $extractedData['language_id'] ?? null;
|
$languageId = $extractedData['language_id'] ?? null;
|
||||||
|
|
||||||
|
$communityId = $this->api->resolveCommunityId($channel->channel_id, $token);
|
||||||
|
|
||||||
return $this->api->createPost(
|
return $this->api->createPost(
|
||||||
$token,
|
$token,
|
||||||
$extractedData['title'] ?? 'Untitled',
|
$extractedData['title'] ?? 'Untitled',
|
||||||
$extractedData['description'] ?? '',
|
$extractedData['description'] ?? '',
|
||||||
$channel->channel_id,
|
$communityId,
|
||||||
$article->url,
|
$article->url,
|
||||||
$extractedData['thumbnail'] ?? null,
|
$extractedData['thumbnail'] ?? null,
|
||||||
$languageId
|
$languageId
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?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}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,16 +12,10 @@
|
||||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class ArticlePublishingService
|
class ArticlePublishingService
|
||||||
{
|
{
|
||||||
private const LOCK_TTL_SECONDS = 180;
|
|
||||||
|
|
||||||
private const LOCK_WAIT_SECONDS = 15;
|
|
||||||
|
|
||||||
public function __construct(private LogSaver $logSaver) {}
|
public function __construct(private LogSaver $logSaver) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,7 +33,7 @@ protected function makePublisher(mixed $account): LemmyPublisher
|
||||||
*
|
*
|
||||||
* @throws PublishException
|
* @throws PublishException
|
||||||
*/
|
*/
|
||||||
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): PublishOutcome
|
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
|
||||||
{
|
{
|
||||||
$article = $routeArticle->article;
|
$article = $routeArticle->article;
|
||||||
$channel = $routeArticle->platformChannel;
|
$channel = $routeArticle->platformChannel;
|
||||||
|
|
@ -60,7 +54,7 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
|
||||||
'route_article_id' => $routeArticle->id,
|
'route_article_id' => $routeArticle->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PublishOutcome::failure('No active account for channel');
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
||||||
|
|
@ -69,51 +63,24 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $extractedData
|
* @param array<string, mixed> $extractedData
|
||||||
*/
|
*/
|
||||||
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): PublishOutcome
|
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
|
||||||
{
|
|
||||||
$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 {
|
try {
|
||||||
// Check if this URL or title was already posted to this channel
|
// Check if this URL or title was already posted to this channel
|
||||||
$title = $extractedData['title'] ?? $article->title;
|
$title = $extractedData['title'] ?? $article->title;
|
||||||
if (PlatformChannelPost::duplicateExists($channel, $article->url, $title)) {
|
if (PlatformChannelPost::duplicateExists(
|
||||||
|
$channel->platformInstance->platform,
|
||||||
|
(string) $channel->channel_id,
|
||||||
|
$article->url,
|
||||||
|
$title
|
||||||
|
)) {
|
||||||
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
|
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'url' => $article->url,
|
'url' => $article->url,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PublishOutcome::skipped('URL or title already posted to this channel');
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$publisher = $this->makePublisher($account);
|
$publisher = $this->makePublisher($account);
|
||||||
|
|
@ -133,14 +100,14 @@ private function doPublishToChannel(Article $article, array $extractedData, Plat
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PublishOutcome::published($publication);
|
return $publication;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return PublishOutcome::failure($e->getMessage());
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'platform_instance_id' => PlatformInstance::factory(),
|
'platform_instance_id' => PlatformInstance::factory(),
|
||||||
'channel_id' => $this->faker->unique()->numberBetween(1, 999999),
|
'channel_id' => $this->faker->slug(2),
|
||||||
'name' => $this->faker->words(2, true),
|
'name' => $this->faker->words(2, true),
|
||||||
'display_name' => $this->faker->words(2, true),
|
'display_name' => $this->faker->words(2, true),
|
||||||
'language_id' => Language::factory(),
|
'language_id' => Language::factory(),
|
||||||
|
|
@ -39,6 +39,7 @@ public function community(?string $name = null): static
|
||||||
$communityName = $name ?: $this->faker->word();
|
$communityName = $name ?: $this->faker->word();
|
||||||
|
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'channel_id' => strtolower($communityName),
|
||||||
'name' => $communityName,
|
'name' => $communityName,
|
||||||
'display_name' => ucfirst($communityName),
|
'display_name' => ucfirst($communityName),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<?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']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
51
docker/build/entrypoint.sh
Normal file
51
docker/build/entrypoint.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/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
|
||||||
59
docker/build/laravel.env
Normal file
59
docker/build/laravel.env
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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=
|
||||||
14
docker/build/wait-for-db.php
Normal file
14
docker/build/wait-for-db.php
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/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);
|
||||||
|
}
|
||||||
11
docker/build/wait-for-redis.php
Normal file
11
docker/build/wait-for-redis.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
try {
|
||||||
|
$redis = new Redis();
|
||||||
|
$redis->connect('redis', 6379);
|
||||||
|
echo 'Connected';
|
||||||
|
exit(0);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
@ -162,11 +162,22 @@ class="w-full inline-flex justify-center rounded-md border border-gray-300 shado
|
||||||
@if ($showCreateModal)
|
@if ($showCreateModal)
|
||||||
<x-form-modal title="Add Channel" close="closeCreateModal">
|
<x-form-modal title="Add Channel" close="closeCreateModal">
|
||||||
<form wire:submit="createChannel" class="space-y-4">
|
<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>
|
<div>
|
||||||
<label for="new-channel-instance" class="block text-sm font-medium text-gray-700">Platform Instance</label>
|
<label for="new-channel-instance" class="block text-sm font-medium text-gray-700">Platform Instance</label>
|
||||||
<select
|
<select
|
||||||
id="new-channel-instance"
|
id="new-channel-instance"
|
||||||
wire:model.live="newPlatformInstanceId"
|
wire:model="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"
|
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>
|
<option value="">Select an instance</option>
|
||||||
|
|
@ -175,37 +186,8 @@ class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:borde
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('newPlatformInstanceId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
|
@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>
|
</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>
|
<div>
|
||||||
<label for="new-channel-language" class="block text-sm font-medium text-gray-700">Language <span class="text-gray-400">(optional)</span></label>
|
<label for="new-channel-language" class="block text-sm font-medium text-gray-700">Language <span class="text-gray-400">(optional)</span></label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -196,13 +196,29 @@ class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
<div>
|
||||||
<label for="platformInstanceId" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="platformInstanceId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Platform Instance
|
Platform Instance
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="platformInstanceId"
|
id="platformInstanceId"
|
||||||
wire:model.live="platformInstanceId"
|
wire:model="platformInstanceId"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
@ -212,40 +228,8 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('platformInstanceId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
|
@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>
|
</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>
|
<div>
|
||||||
<label for="channelLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
|
<label for="channelLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Language
|
Language
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,32 +6,12 @@
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class PlatformChannelsControllerTest extends TestCase
|
class PlatformChannelsControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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
|
public function test_index_returns_successful_response(): void
|
||||||
{
|
{
|
||||||
$instance = PlatformInstance::factory()->create();
|
$instance = PlatformInstance::factory()->create();
|
||||||
|
|
@ -76,7 +56,7 @@ public function test_store_creates_platform_channel_successfully(): void
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'channel_id' => 8,
|
'name' => 'test_channel',
|
||||||
'description' => 'A test channel',
|
'description' => 'A test channel',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -105,7 +85,8 @@ public function test_store_creates_platform_channel_successfully(): void
|
||||||
|
|
||||||
$this->assertDatabaseHas('platform_channels', [
|
$this->assertDatabaseHas('platform_channels', [
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'channel_id' => 8,
|
'channel_id' => 'test_channel',
|
||||||
|
'name' => 'test_channel',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,14 +95,14 @@ public function test_store_validates_required_fields(): void
|
||||||
$response = $this->postJson('/api/v1/platform-channels', []);
|
$response = $this->postJson('/api/v1/platform-channels', []);
|
||||||
|
|
||||||
$response->assertStatus(422)
|
$response->assertStatus(422)
|
||||||
->assertJsonValidationErrors(['platform_instance_id', 'channel_id']);
|
->assertJsonValidationErrors(['platform_instance_id', 'name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_validates_platform_instance_exists(): void
|
public function test_store_validates_platform_instance_exists(): void
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'platform_instance_id' => 999,
|
'platform_instance_id' => 999,
|
||||||
'channel_id' => 8,
|
'name' => 'Test Channel',
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/platform-channels', $data);
|
$response = $this->postJson('/api/v1/platform-channels', $data);
|
||||||
|
|
@ -130,62 +111,44 @@ public function test_store_validates_platform_instance_exists(): void
|
||||||
->assertJsonValidationErrors(['platform_instance_id']);
|
->assertJsonValidationErrors(['platform_instance_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_rejects_community_not_on_instance(): void
|
public function test_store_rejects_non_slug_name(): void
|
||||||
{
|
{
|
||||||
$instance = PlatformInstance::factory()->create();
|
$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', [
|
$response = $this->postJson('/api/v1/platform-channels', [
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'channel_id' => 4242,
|
'name' => 'Tech News',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(422)
|
$response->assertStatus(422)
|
||||||
->assertJsonValidationErrors(['channel_id']);
|
->assertJsonValidationErrors(['name']);
|
||||||
|
|
||||||
$this->assertDatabaseCount('platform_channels', 0);
|
$this->assertDatabaseCount('platform_channels', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_distinguishes_an_unreachable_instance_from_a_missing_community(): void
|
public function test_store_rejects_duplicate_name_for_same_instance(): 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();
|
$instance = PlatformInstance::factory()->create();
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => 'tech_news',
|
'name' => 'tech_news',
|
||||||
'channel_id' => 9,
|
'channel_id' => 'tech_news',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/platform-channels', [
|
$response = $this->postJson('/api/v1/platform-channels', [
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'channel_id' => 9,
|
'name' => 'tech_news',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(422)
|
$response->assertStatus(422)
|
||||||
->assertJsonValidationErrors(['channel_id']);
|
->assertJsonValidationErrors(['name']);
|
||||||
|
|
||||||
$this->assertDatabaseCount('platform_channels', 1);
|
$this->assertDatabaseCount('platform_channels', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_store_allows_same_community_on_different_instance(): void
|
public function test_store_allows_same_name_on_different_instance(): void
|
||||||
{
|
{
|
||||||
$instanceA = PlatformInstance::factory()->create();
|
$instanceA = PlatformInstance::factory()->create();
|
||||||
$instanceB = PlatformInstance::factory()->create();
|
$instanceB = PlatformInstance::factory()->create();
|
||||||
|
|
@ -198,12 +161,12 @@ public function test_store_allows_same_community_on_different_instance(): void
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instanceA->id,
|
'platform_instance_id' => $instanceA->id,
|
||||||
'name' => 'tech_news',
|
'name' => 'tech_news',
|
||||||
'channel_id' => 9,
|
'channel_id' => 'tech_news',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->postJson('/api/v1/platform-channels', [
|
$response = $this->postJson('/api/v1/platform-channels', [
|
||||||
'platform_instance_id' => $instanceB->id,
|
'platform_instance_id' => $instanceB->id,
|
||||||
'channel_id' => 9,
|
'name' => 'tech_news',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<?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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace Tests\Feature\Listeners;
|
namespace Tests\Feature\Listeners;
|
||||||
|
|
||||||
use App\Actions\PublishRouteArticleAction;
|
|
||||||
use App\Enums\NotificationSeverityEnum;
|
use App\Enums\NotificationSeverityEnum;
|
||||||
use App\Enums\NotificationTypeEnum;
|
use App\Enums\NotificationTypeEnum;
|
||||||
use App\Events\RouteArticleApproved;
|
use App\Events\RouteArticleApproved;
|
||||||
|
|
@ -16,7 +15,6 @@
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Notification\NotificationService;
|
use App\Services\Notification\NotificationService;
|
||||||
use App\Services\Publishing\ArticlePublishingService;
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use App\Services\Publishing\PublishOutcome;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
|
@ -55,7 +53,7 @@ public function test_exception_during_publishing_creates_error_notification(): v
|
||||||
|
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
|
|
||||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||||
|
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
|
@ -84,9 +82,9 @@ public function test_no_publication_created_creates_warning_notification(): void
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::failure('No publication created'));
|
->andReturn(null);
|
||||||
|
|
||||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||||
|
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
|
@ -114,9 +112,9 @@ public function test_successful_publish_does_not_create_notification(): void
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||||
|
|
||||||
$this->assertDatabaseCount('notifications', 0);
|
$this->assertDatabaseCount('notifications', 0);
|
||||||
|
|
@ -137,7 +135,7 @@ public function test_skips_already_published_to_channel(): void
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||||
|
|
||||||
$listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, new NotificationService));
|
$listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService);
|
||||||
$listener->handle(new RouteArticleApproved($routeArticle));
|
$listener->handle(new RouteArticleApproved($routeArticle));
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
|
|
@ -148,12 +146,4 @@ protected function tearDown(): void
|
||||||
Mockery::close();
|
Mockery::close();
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makePublication(): ArticlePublication
|
|
||||||
{
|
|
||||||
/** @var ArticlePublication $publication */
|
|
||||||
$publication = ArticlePublication::factory()->make();
|
|
||||||
|
|
||||||
return $publication;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\Client\Factory;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -18,23 +15,6 @@ class ChannelsTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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
|
private function instanceWithActiveAccount(): PlatformInstance
|
||||||
{
|
{
|
||||||
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
$instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.world']);
|
||||||
|
|
@ -70,7 +50,7 @@ public function test_open_create_modal_shows_modal(): void
|
||||||
->assertSet('showCreateModal', true);
|
->assertSet('showCreateModal', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_channel_requires_community(): void
|
public function test_create_channel_requires_name(): void
|
||||||
{
|
{
|
||||||
$instance = $this->instanceWithActiveAccount();
|
$instance = $this->instanceWithActiveAccount();
|
||||||
|
|
||||||
|
|
@ -78,44 +58,14 @@ public function test_create_channel_requires_community(): void
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasErrors(['newCommunityId' => 'required']);
|
->assertHasErrors(['newName' => '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
|
public function test_create_channel_requires_platform_instance(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
->set('newCommunityId', 8)
|
->set('newName', 'tech_community')
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasErrors(['newPlatformInstanceId' => 'required']);
|
->assertHasErrors(['newPlatformInstanceId' => 'required']);
|
||||||
}
|
}
|
||||||
|
|
@ -127,8 +77,8 @@ public function test_create_channel_succeeds_and_attaches_account(): void
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'tech_community')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->set('newCommunityId', 8)
|
|
||||||
->set('newLanguageId', $language->id)
|
->set('newLanguageId', $language->id)
|
||||||
->set('newDescription', 'A tech community')
|
->set('newDescription', 'A tech community')
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
|
|
@ -154,8 +104,8 @@ public function test_create_channel_leaves_description_null_when_blank(): void
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'tech_community')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->set('newCommunityId', 8)
|
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
|
@ -165,41 +115,40 @@ public function test_create_channel_leaves_description_null_when_blank(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_channel_rejects_a_community_not_on_the_instance(): void
|
public function test_create_channel_rejects_non_slug_name(): void
|
||||||
{
|
{
|
||||||
$instance = $this->instanceWithActiveAccount();
|
$instance = $this->instanceWithActiveAccount();
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'Tech News')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->set('newCommunityId', 4242)
|
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasErrors(['newCommunityId' => 'in']);
|
->assertHasErrors(['newName' => 'regex']);
|
||||||
|
|
||||||
$this->assertDatabaseCount('platform_channels', 0);
|
$this->assertDatabaseCount('platform_channels', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_channel_rejects_duplicate_community_on_same_instance(): void
|
public function test_create_channel_rejects_duplicate_name_on_same_instance(): void
|
||||||
{
|
{
|
||||||
$instance = $this->instanceWithActiveAccount();
|
$instance = $this->instanceWithActiveAccount();
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => 'tech_community',
|
'name' => 'tech_community',
|
||||||
'channel_id' => 8,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'tech_community')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->set('newCommunityId', 8)
|
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasErrors(['newCommunityId' => 'unique'])
|
->assertHasErrors(['newName' => 'unique'])
|
||||||
->assertSet('showCreateModal', true);
|
->assertSet('showCreateModal', true);
|
||||||
|
|
||||||
$this->assertDatabaseCount('platform_channels', 1);
|
$this->assertDatabaseCount('platform_channels', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_channel_allows_same_community_on_different_instance(): void
|
public function test_create_channel_allows_same_name_on_different_instance(): void
|
||||||
{
|
{
|
||||||
$instanceA = $this->instanceWithActiveAccount();
|
$instanceA = $this->instanceWithActiveAccount();
|
||||||
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
|
$instanceB = PlatformInstance::factory()->create(['url' => 'https://lemmy.other']);
|
||||||
|
|
@ -210,13 +159,12 @@ public function test_create_channel_allows_same_community_on_different_instance(
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instanceB->id,
|
'platform_instance_id' => $instanceB->id,
|
||||||
'name' => 'tech_community',
|
'name' => 'tech_community',
|
||||||
'channel_id' => 8,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'tech_community')
|
||||||
->set('newPlatformInstanceId', $instanceA->id)
|
->set('newPlatformInstanceId', $instanceA->id)
|
||||||
->set('newCommunityId', 8)
|
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
|
@ -230,8 +178,8 @@ public function test_create_channel_surfaces_no_active_accounts_error(): void
|
||||||
|
|
||||||
Livewire::test(Channels::class)
|
Livewire::test(Channels::class)
|
||||||
->call('openCreateModal')
|
->call('openCreateModal')
|
||||||
|
->set('newName', 'tech_community')
|
||||||
->set('newPlatformInstanceId', $instance->id)
|
->set('newPlatformInstanceId', $instance->id)
|
||||||
->set('newCommunityId', 8)
|
|
||||||
->call('createChannel')
|
->call('createChannel')
|
||||||
->assertHasErrors('newPlatformInstanceId')
|
->assertHasErrors('newPlatformInstanceId')
|
||||||
->assertSet('showCreateModal', true);
|
->assertSet('showCreateModal', true);
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
<?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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -31,14 +31,11 @@ public function test_creates_channel_and_attaches_account(): void
|
||||||
]);
|
]);
|
||||||
$language = Language::factory()->create();
|
$language = Language::factory()->create();
|
||||||
|
|
||||||
$channel = $this->action->execute(
|
$channel = $this->action->execute('test_community', $instance->id, $language->id, 'A description');
|
||||||
'test_community',
|
|
||||||
8,
|
|
||||||
$instance->id, $language->id, 'A description');
|
|
||||||
|
|
||||||
$this->assertInstanceOf(PlatformChannel::class, $channel);
|
$this->assertInstanceOf(PlatformChannel::class, $channel);
|
||||||
$this->assertEquals('test_community', $channel->name);
|
$this->assertEquals('test_community', $channel->name);
|
||||||
$this->assertSame(8, $channel->channel_id);
|
$this->assertEquals('test_community', $channel->channel_id);
|
||||||
$this->assertEquals('Test_community', $channel->display_name);
|
$this->assertEquals('Test_community', $channel->display_name);
|
||||||
$this->assertEquals($instance->id, $channel->platform_instance_id);
|
$this->assertEquals($instance->id, $channel->platform_instance_id);
|
||||||
$this->assertEquals($language->id, $channel->language_id);
|
$this->assertEquals($language->id, $channel->language_id);
|
||||||
|
|
@ -58,10 +55,7 @@ public function test_creates_channel_without_language(): void
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = $this->action->execute(
|
$channel = $this->action->execute('test_community', $instance->id);
|
||||||
'test_community',
|
|
||||||
8,
|
|
||||||
$instance->id);
|
|
||||||
|
|
||||||
$this->assertNull($channel->language_id);
|
$this->assertNull($channel->language_id);
|
||||||
}
|
}
|
||||||
|
|
@ -78,10 +72,7 @@ public function test_fails_when_no_active_accounts(): void
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
$this->expectExceptionMessage('No active platform accounts found for this instance');
|
$this->expectExceptionMessage('No active platform accounts found for this instance');
|
||||||
|
|
||||||
$this->action->execute(
|
$this->action->execute('test_community', $instance->id);
|
||||||
'test_community',
|
|
||||||
8,
|
|
||||||
$instance->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_fails_when_no_accounts_at_all(): void
|
public function test_fails_when_no_accounts_at_all(): void
|
||||||
|
|
@ -90,9 +81,6 @@ public function test_fails_when_no_accounts_at_all(): void
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
$this->action->execute(
|
$this->action->execute('test_community', $instance->id);
|
||||||
'test_community',
|
|
||||||
8,
|
|
||||||
$instance->id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace Tests\Unit\Jobs;
|
namespace Tests\Unit\Jobs;
|
||||||
|
|
||||||
use App\Actions\PublishRouteArticleAction;
|
|
||||||
use App\Enums\NotificationSeverityEnum;
|
use App\Enums\NotificationSeverityEnum;
|
||||||
use App\Enums\NotificationTypeEnum;
|
use App\Enums\NotificationTypeEnum;
|
||||||
use App\Exceptions\PublishException;
|
use App\Exceptions\PublishException;
|
||||||
|
|
@ -17,7 +16,6 @@
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Notification\NotificationService;
|
use App\Services\Notification\NotificationService;
|
||||||
use App\Services\Publishing\ArticlePublishingService;
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use App\Services\Publishing\PublishOutcome;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
@ -97,7 +95,7 @@ public function test_handle_returns_early_when_no_approved_route_articles(): voi
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +114,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_route_art
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +132,7 @@ public function test_handle_skips_non_approved_route_articles(): void
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -172,10 +170,10 @@ public function test_handle_publishes_oldest_approved_route_article(): void
|
||||||
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
|
Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
|
||||||
$extractedData
|
$extractedData
|
||||||
)
|
)
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +200,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void
|
||||||
|
|
||||||
$this->expectException(PublishException::class);
|
$this->expectException(PublishException::class);
|
||||||
|
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
|
public function test_handle_skips_publishing_when_last_publication_within_interval(): void
|
||||||
|
|
@ -221,7 +219,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
|
||||||
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -245,10 +243,10 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -272,10 +270,10 @@ public function test_handle_publishes_when_interval_is_zero(): void
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -299,10 +297,10 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval(
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -323,10 +321,10 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::published($this->makePublication()));
|
->andReturn(ArticlePublication::factory()->make());
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
@ -345,10 +343,10 @@ public function test_handle_creates_warning_notification_when_no_publication_cre
|
||||||
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
|
||||||
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
$publishingServiceMock->shouldReceive('publishRouteArticle')
|
||||||
->once()
|
->once()
|
||||||
->andReturn(PublishOutcome::failure('No publication created'));
|
->andReturn(null);
|
||||||
|
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
|
||||||
|
|
@ -382,7 +380,7 @@ public function test_handle_creates_notification_on_publish_exception(): void
|
||||||
$job = new PublishNextArticleJob;
|
$job = new PublishNextArticleJob;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
|
$job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService);
|
||||||
} catch (PublishException) {
|
} catch (PublishException) {
|
||||||
// Expected
|
// Expected
|
||||||
}
|
}
|
||||||
|
|
@ -415,12 +413,4 @@ protected function tearDown(): void
|
||||||
Mockery::close();
|
Mockery::close();
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makePublication(): ArticlePublication
|
|
||||||
{
|
|
||||||
/** @var ArticlePublication $publication */
|
|
||||||
$publication = ArticlePublication::factory()->make();
|
|
||||||
|
|
||||||
return $publication;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,18 +133,22 @@ public function test_handle_logs_start_message(): void
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_sync_passes_the_stored_community_id(): void
|
public function test_sync_resolves_non_numeric_channel_id_via_get_community_id(): void
|
||||||
{
|
{
|
||||||
[$channel, $account] = $this->makeSyncableChannel(42);
|
[$channel, $account] = $this->makeSyncableChannel('tech_news');
|
||||||
|
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
$apiMock->shouldReceive('login')
|
$apiMock->shouldReceive('login')
|
||||||
->once()
|
->once()
|
||||||
->with($account->username, $account->password)
|
->with($account->username, $account->password)
|
||||||
->andReturn('token');
|
->andReturn('token');
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('tech_news', 'token')
|
||||||
|
->andReturn(42);
|
||||||
$apiMock->shouldReceive('syncChannelPosts')
|
$apiMock->shouldReceive('syncChannelPosts')
|
||||||
->once()
|
->once()
|
||||||
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
|
->with('token', 42, $channel->name);
|
||||||
|
|
||||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||||
|
|
@ -157,18 +161,25 @@ public function test_sync_passes_the_stored_community_id(): void
|
||||||
$this->addToAssertionCount(1);
|
$this->addToAssertionCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_sync_uses_channel_id_without_a_lookup(): void
|
public function test_sync_passes_resolved_community_id_to_sync_channel_posts(): void
|
||||||
{
|
{
|
||||||
[$channel, $account] = $this->makeSyncableChannel(42);
|
[$channel, $account] = $this->makeSyncableChannel('42');
|
||||||
|
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
$apiMock->shouldReceive('login')
|
$apiMock->shouldReceive('login')
|
||||||
->once()
|
->once()
|
||||||
->with($account->username, $account->password)
|
->with($account->username, $account->password)
|
||||||
->andReturn('token');
|
->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')
|
$apiMock->shouldReceive('syncChannelPosts')
|
||||||
->once()
|
->once()
|
||||||
->with('token', Mockery::on(fn ($arg) => $arg->is($channel)), 42);
|
->with('token', 42, $channel->name);
|
||||||
|
|
||||||
$logSaverMock = Mockery::mock(LogSaver::class);
|
$logSaverMock = Mockery::mock(LogSaver::class);
|
||||||
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
$logSaverMock->shouldReceive('info')->zeroOrMoreTimes();
|
||||||
|
|
@ -184,7 +195,7 @@ public function test_sync_uses_channel_id_without_a_lookup(): void
|
||||||
/**
|
/**
|
||||||
* @return array{0: PlatformChannel, 1: PlatformAccount}
|
* @return array{0: PlatformChannel, 1: PlatformAccount}
|
||||||
*/
|
*/
|
||||||
private function makeSyncableChannel(int $channelId): array
|
private function makeSyncableChannel(string $channelId): array
|
||||||
{
|
{
|
||||||
$platformInstance = PlatformInstance::factory()->create([
|
$platformInstance = PlatformInstance::factory()->create([
|
||||||
'platform' => PlatformEnum::LEMMY,
|
'platform' => PlatformEnum::LEMMY,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => 'tech',
|
'name' => 'tech',
|
||||||
'channel_id' => 7,
|
'channel_id' => 'tech',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(UniqueConstraintViolationException::class);
|
$this->expectException(UniqueConstraintViolationException::class);
|
||||||
|
|
@ -60,7 +60,7 @@ public function test_channel_id_is_unique_per_platform_instance(): void
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => 'tech_alias',
|
'name' => 'tech_alias',
|
||||||
'channel_id' => 7,
|
'channel_id' => 'tech',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,15 +71,15 @@ public function test_same_channel_id_allowed_across_different_instances(): void
|
||||||
|
|
||||||
PlatformChannel::factory()->create([
|
PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instanceA->id,
|
'platform_instance_id' => $instanceA->id,
|
||||||
'channel_id' => 7,
|
'channel_id' => 'tech',
|
||||||
]);
|
]);
|
||||||
$second = PlatformChannel::factory()->create([
|
$second = PlatformChannel::factory()->create([
|
||||||
'platform_instance_id' => $instanceB->id,
|
'platform_instance_id' => $instanceB->id,
|
||||||
'channel_id' => 7,
|
'channel_id' => 'tech',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseCount('platform_channels', 2);
|
$this->assertDatabaseCount('platform_channels', 2);
|
||||||
$this->assertSame(7, $second->channel_id);
|
$this->assertEquals('tech', $second->channel_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_belongs_to_platform_instance_relationship(): void
|
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->assertInstanceOf(PlatformChannel::class, $channel);
|
||||||
$this->assertNotNull($channel->platform_instance_id);
|
$this->assertNotNull($channel->platform_instance_id);
|
||||||
$this->assertIsString($channel->name);
|
$this->assertIsString($channel->name);
|
||||||
$this->assertIsInt($channel->channel_id);
|
$this->assertIsString($channel->channel_id);
|
||||||
$this->assertIsBool($channel->is_active);
|
$this->assertIsBool($channel->is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,7 +272,7 @@ public function test_channel_creation_with_explicit_values(): void
|
||||||
'platform_instance_id' => $instance->id,
|
'platform_instance_id' => $instance->id,
|
||||||
'name' => 'test_channel',
|
'name' => 'test_channel',
|
||||||
'display_name' => 'Test Channel',
|
'display_name' => 'Test Channel',
|
||||||
'channel_id' => 123,
|
'channel_id' => 'channel_123',
|
||||||
'description' => 'A test channel',
|
'description' => 'A test channel',
|
||||||
'language_id' => $language->id,
|
'language_id' => $language->id,
|
||||||
'is_active' => false,
|
'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($instance->id, $channel->platform_instance_id);
|
||||||
$this->assertEquals('test_channel', $channel->name);
|
$this->assertEquals('test_channel', $channel->name);
|
||||||
$this->assertEquals('Test Channel', $channel->display_name);
|
$this->assertEquals('Test Channel', $channel->display_name);
|
||||||
$this->assertSame(123, $channel->channel_id);
|
$this->assertEquals('channel_123', $channel->channel_id);
|
||||||
$this->assertEquals('A test channel', $channel->description);
|
$this->assertEquals('A test channel', $channel->description);
|
||||||
$this->assertEquals($language->id, $channel->language_id);
|
$this->assertEquals($language->id, $channel->language_id);
|
||||||
$this->assertFalse($channel->is_active);
|
$this->assertFalse($channel->is_active);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
namespace Tests\Unit\Models;
|
namespace Tests\Unit\Models;
|
||||||
|
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Events\RouteArticleApproved;
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
|
|
@ -11,7 +10,6 @@
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class RouteArticleTest extends TestCase
|
class RouteArticleTest extends TestCase
|
||||||
|
|
@ -62,34 +60,6 @@ public function test_route_article_can_be_approved(): void
|
||||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
|
$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
|
public function test_route_article_can_be_rejected(): void
|
||||||
{
|
{
|
||||||
/** @var RouteArticle $routeArticle */
|
/** @var RouteArticle $routeArticle */
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Unit\Modules\Lemmy\Services;
|
namespace Tests\Unit\Modules\Lemmy\Services;
|
||||||
|
|
||||||
use App\Models\PlatformChannel;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -13,13 +13,6 @@ class LemmyApiServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private ?PlatformChannel $channel = null;
|
|
||||||
|
|
||||||
private function syncChannel(): PlatformChannel
|
|
||||||
{
|
|
||||||
return $this->channel ??= PlatformChannel::factory()->create();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_constructor_sets_instance(): void
|
public function test_constructor_sets_instance(): void
|
||||||
{
|
{
|
||||||
$service = new LemmyApiService('lemmy.world');
|
$service = new LemmyApiService('lemmy.world');
|
||||||
|
|
@ -170,40 +163,35 @@ public function test_get_community_id_success(): void
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_list_communities_returns_local_communities(): void
|
public function test_resolve_community_id_looks_up_non_numeric_channel_id(): void
|
||||||
{
|
{
|
||||||
Http::fake(['*' => Http::response(['communities' => [
|
Http::fake([
|
||||||
['community' => ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
'*' => Http::response([
|
||||||
['community' => ['id' => 9, 'name' => 'memes', 'title' => 'Memes']],
|
'community_view' => [
|
||||||
]], 200)]);
|
'community' => ['id' => 123],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
$communities = (new LemmyApiService('lemmy.world'))->listCommunities();
|
$service = new LemmyApiService('lemmy.world');
|
||||||
|
$id = $service->resolveCommunityId('test-community', 'token');
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(123, $id);
|
||||||
[['id' => 9, 'name' => 'memes', 'title' => 'Memes'], ['id' => 8, 'name' => 'news', 'title' => 'News']],
|
|
||||||
$communities
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'name=test-community'));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_list_communities_omits_removed_and_deleted(): void
|
public function test_resolve_community_id_uses_numeric_channel_id_without_lookup(): void
|
||||||
{
|
{
|
||||||
Http::fake(['*' => Http::response(['communities' => [
|
Http::fake();
|
||||||
['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)]);
|
|
||||||
|
|
||||||
$this->assertSame([8], collect((new LemmyApiService('lemmy.world'))->listCommunities())->pluck('id')->all());
|
$service = new LemmyApiService('lemmy.world');
|
||||||
}
|
$id = $service->resolveCommunityId('42', 'token');
|
||||||
|
|
||||||
public function test_list_communities_throws_on_unsuccessful_response(): void
|
$this->assertSame(42, $id);
|
||||||
{
|
|
||||||
Http::fake(['*' => Http::response('nope', 500)]);
|
|
||||||
|
|
||||||
$this->expectException(Exception::class);
|
// A numeric channel_id is already the community id — no lookup should happen.
|
||||||
$this->expectExceptionMessage('Failed to list communities: 500');
|
Http::assertNothingSent();
|
||||||
|
|
||||||
(new LemmyApiService('lemmy.world'))->listCommunities();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_get_community_id_throws_on_unsuccessful_response(): void
|
public function test_get_community_id_throws_on_unsuccessful_response(): void
|
||||||
|
|
@ -260,7 +248,7 @@ public function test_sync_channel_posts_success(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new LemmyApiService('lemmy.world');
|
$service = new LemmyApiService('lemmy.world');
|
||||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
$service->syncChannelPosts('token', 42, 'test-community');
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
Http::assertSent(function ($request) {
|
||||||
return str_contains($request->url(), '/api/v3/post/list')
|
return str_contains($request->url(), '/api/v3/post/list')
|
||||||
|
|
@ -271,14 +259,18 @@ public function test_sync_channel_posts_success(): void
|
||||||
|
|
||||||
// Verify posts were stored in the database
|
// Verify posts were stored in the database
|
||||||
$this->assertDatabaseHas('platform_channel_posts', [
|
$this->assertDatabaseHas('platform_channel_posts', [
|
||||||
'platform_channel_id' => $this->syncChannel()->id,
|
'platform' => PlatformEnum::LEMMY->value,
|
||||||
|
'channel_id' => '42',
|
||||||
|
'channel_name' => 'test-community',
|
||||||
'post_id' => '1',
|
'post_id' => '1',
|
||||||
'url' => 'https://example.com/1',
|
'url' => 'https://example.com/1',
|
||||||
'title' => 'Post 1',
|
'title' => 'Post 1',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('platform_channel_posts', [
|
$this->assertDatabaseHas('platform_channel_posts', [
|
||||||
'platform_channel_id' => $this->syncChannel()->id,
|
'platform' => PlatformEnum::LEMMY->value,
|
||||||
|
'channel_id' => '42',
|
||||||
|
'channel_name' => 'test-community',
|
||||||
'post_id' => '2',
|
'post_id' => '2',
|
||||||
'url' => 'https://example.com/2',
|
'url' => 'https://example.com/2',
|
||||||
'title' => 'Post 2',
|
'title' => 'Post 2',
|
||||||
|
|
@ -292,7 +284,7 @@ public function test_sync_channel_posts_handles_unsuccessful_response(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = new LemmyApiService('lemmy.world');
|
$service = new LemmyApiService('lemmy.world');
|
||||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
$service->syncChannelPosts('token', 42, 'test-community');
|
||||||
|
|
||||||
Http::assertSentCount(1);
|
Http::assertSentCount(1);
|
||||||
$this->assertDatabaseCount('platform_channel_posts', 0);
|
$this->assertDatabaseCount('platform_channel_posts', 0);
|
||||||
|
|
@ -305,7 +297,7 @@ public function test_sync_channel_posts_handles_exception(): void
|
||||||
});
|
});
|
||||||
|
|
||||||
$service = new LemmyApiService('lemmy.world');
|
$service = new LemmyApiService('lemmy.world');
|
||||||
$service->syncChannelPosts('token', $this->syncChannel(), 42);
|
$service->syncChannelPosts('token', 42, 'test-community');
|
||||||
|
|
||||||
// Assert that the method completes without throwing
|
// Assert that the method completes without throwing
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public function test_publish_to_channel_with_all_data(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = PlatformChannel::factory()->make([
|
$channel = PlatformChannel::factory()->make([
|
||||||
'channel_id' => 42,
|
'channel_id' => '42',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedData = [
|
$extractedData = [
|
||||||
|
|
@ -76,6 +76,10 @@ public function test_publish_to_channel_with_all_data(): void
|
||||||
|
|
||||||
// Mock LemmyApiService
|
// Mock LemmyApiService
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('42', 'test-token')
|
||||||
|
->andReturn(42);
|
||||||
$apiMock->shouldReceive('createPost')
|
$apiMock->shouldReceive('createPost')
|
||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
|
|
@ -113,7 +117,7 @@ public function test_publish_to_channel_with_minimal_data(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = PlatformChannel::factory()->make([
|
$channel = PlatformChannel::factory()->make([
|
||||||
'channel_id' => 24,
|
'channel_id' => '24',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedData = [];
|
$extractedData = [];
|
||||||
|
|
@ -129,6 +133,10 @@ public function test_publish_to_channel_with_minimal_data(): void
|
||||||
|
|
||||||
// Mock LemmyApiService
|
// Mock LemmyApiService
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('24', 'minimal-token')
|
||||||
|
->andReturn(24);
|
||||||
$apiMock->shouldReceive('createPost')
|
$apiMock->shouldReceive('createPost')
|
||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
|
|
@ -166,7 +174,7 @@ public function test_publish_to_channel_without_thumbnail(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = PlatformChannel::factory()->make([
|
$channel = PlatformChannel::factory()->make([
|
||||||
'channel_id' => 33,
|
'channel_id' => '33',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedData = [
|
$extractedData = [
|
||||||
|
|
@ -185,6 +193,10 @@ public function test_publish_to_channel_without_thumbnail(): void
|
||||||
|
|
||||||
// Mock LemmyApiService
|
// Mock LemmyApiService
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('33', 'no-thumb-token')
|
||||||
|
->andReturn(33);
|
||||||
$apiMock->shouldReceive('createPost')
|
$apiMock->shouldReceive('createPost')
|
||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
|
|
@ -248,7 +260,7 @@ public function test_publish_to_channel_throws_api_exception(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = PlatformChannel::factory()->make([
|
$channel = PlatformChannel::factory()->make([
|
||||||
'channel_id' => 42,
|
'channel_id' => '42',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedData = [
|
$extractedData = [
|
||||||
|
|
@ -266,6 +278,10 @@ public function test_publish_to_channel_throws_api_exception(): void
|
||||||
|
|
||||||
// Mock LemmyApiService to throw exception
|
// Mock LemmyApiService to throw exception
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('42', 'test-token')
|
||||||
|
->andReturn(42);
|
||||||
$apiMock->shouldReceive('createPost')
|
$apiMock->shouldReceive('createPost')
|
||||||
->once()
|
->once()
|
||||||
->andThrow(new Exception('API Error'));
|
->andThrow(new Exception('API Error'));
|
||||||
|
|
@ -295,7 +311,7 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$channel = PlatformChannel::factory()->make([
|
$channel = PlatformChannel::factory()->make([
|
||||||
'channel_id' => 42,
|
'channel_id' => 'string-42',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedData = [
|
$extractedData = [
|
||||||
|
|
@ -311,6 +327,10 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
|
||||||
|
|
||||||
// Mock LemmyApiService - should resolve non-numeric channel_id to a community id
|
// Mock LemmyApiService - should resolve non-numeric channel_id to a community id
|
||||||
$apiMock = Mockery::mock(LemmyApiService::class);
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('resolveCommunityId')
|
||||||
|
->once()
|
||||||
|
->with('string-42', 'token')
|
||||||
|
->andReturn(42);
|
||||||
$apiMock->shouldReceive('createPost')
|
$apiMock->shouldReceive('createPost')
|
||||||
->once()
|
->once()
|
||||||
->with(
|
->with(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
namespace Tests\Unit\Services\Publishing;
|
namespace Tests\Unit\Services\Publishing;
|
||||||
|
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\ArticlePublication;
|
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
|
|
@ -15,10 +15,7 @@
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
use App\Services\Publishing\ArticlePublishingService;
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Cache\Lock;
|
|
||||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Mockery;
|
use Mockery;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -98,7 +95,7 @@ public function test_publish_route_article_returns_null_when_no_active_account()
|
||||||
|
|
||||||
$result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);
|
$result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);
|
||||||
|
|
||||||
$this->assertTrue($result->failed());
|
$this->assertNull($result);
|
||||||
$this->assertDatabaseCount('article_publications', 0);
|
$this->assertDatabaseCount('article_publications', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +114,7 @@ public function test_publish_route_article_successfully_publishes(): void
|
||||||
|
|
||||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||||
|
|
||||||
$this->assertTrue($result->succeeded());
|
$this->assertNotNull($result);
|
||||||
$this->assertDatabaseHas('article_publications', [
|
$this->assertDatabaseHas('article_publications', [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'platform_channel_id' => $channel->id,
|
'platform_channel_id' => $channel->id,
|
||||||
|
|
@ -126,73 +123,6 @@ 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
|
public function test_publish_route_article_handles_publishing_failure_gracefully(): void
|
||||||
{
|
{
|
||||||
[$routeArticle] = $this->createRouteArticleWithAccount();
|
[$routeArticle] = $this->createRouteArticleWithAccount();
|
||||||
|
|
@ -208,7 +138,7 @@ public function test_publish_route_article_handles_publishing_failure_gracefully
|
||||||
|
|
||||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
|
||||||
|
|
||||||
$this->assertTrue($result->failed());
|
$this->assertNull($result);
|
||||||
$this->assertDatabaseCount('article_publications', 0);
|
$this->assertDatabaseCount('article_publications', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,7 +148,9 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
|
||||||
|
|
||||||
// Simulate the URL already being posted to this channel
|
// Simulate the URL already being posted to this channel
|
||||||
PlatformChannelPost::storePost(
|
PlatformChannelPost::storePost(
|
||||||
$channel,
|
PlatformEnum::LEMMY,
|
||||||
|
(string) $channel->channel_id,
|
||||||
|
$channel->name,
|
||||||
'999',
|
'999',
|
||||||
$article->url,
|
$article->url,
|
||||||
'Different Title',
|
'Different Title',
|
||||||
|
|
@ -233,7 +165,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel(
|
||||||
|
|
||||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
|
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);
|
||||||
|
|
||||||
$this->assertTrue($result->wasSkipped());
|
$this->assertNull($result);
|
||||||
$this->assertDatabaseCount('article_publications', 0);
|
$this->assertDatabaseCount('article_publications', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,7 +175,9 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
|
||||||
|
|
||||||
// Simulate the same title already posted with a different URL
|
// Simulate the same title already posted with a different URL
|
||||||
PlatformChannelPost::storePost(
|
PlatformChannelPost::storePost(
|
||||||
$channel,
|
PlatformEnum::LEMMY,
|
||||||
|
(string) $channel->channel_id,
|
||||||
|
$channel->name,
|
||||||
'888',
|
'888',
|
||||||
'https://example.com/different-url',
|
'https://example.com/different-url',
|
||||||
'Breaking News',
|
'Breaking News',
|
||||||
|
|
@ -258,7 +192,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe
|
||||||
|
|
||||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
|
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);
|
||||||
|
|
||||||
$this->assertTrue($result->wasSkipped());
|
$this->assertNull($result);
|
||||||
$this->assertDatabaseCount('article_publications', 0);
|
$this->assertDatabaseCount('article_publications', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,7 +202,9 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
|
||||||
|
|
||||||
// Existing post in the channel has a completely different URL and title
|
// Existing post in the channel has a completely different URL and title
|
||||||
PlatformChannelPost::storePost(
|
PlatformChannelPost::storePost(
|
||||||
$channel,
|
PlatformEnum::LEMMY,
|
||||||
|
(string) $channel->channel_id,
|
||||||
|
$channel->name,
|
||||||
'777',
|
'777',
|
||||||
'https://example.com/other-article',
|
'https://example.com/other-article',
|
||||||
'Totally Different Title',
|
'Totally Different Title',
|
||||||
|
|
@ -285,7 +221,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void
|
||||||
|
|
||||||
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
|
$result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);
|
||||||
|
|
||||||
$this->assertTrue($result->succeeded());
|
$this->assertNotNull($result);
|
||||||
$this->assertDatabaseHas('article_publications', [
|
$this->assertDatabaseHas('article_publications', [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'post_id' => 456,
|
'post_id' => 456,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue